From 45b04e6b92ebb28c68a984ef241ca0c2d91ab71a Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Fri, 2 Sep 2022 11:12:32 +0200 Subject: [PATCH 01/45] Move curve visualization to elevated submodule --- qiskit_experiments/curve_analysis/__init__.py | 13 --- .../curve_analysis/base_curve_analysis.py | 2 +- .../composite_curve_analysis.py | 2 +- .../curve_analysis/visualization/__init__.py | 31 -------- .../library/quantum_volume/qv_analysis.py | 2 +- qiskit_experiments/visualization/__init__.py | 79 +++++++++++++++++++ .../visualization/base_drawer.py | 0 .../visualization/curves.py | 0 .../visualization/fit_result_plotters.py | 0 .../visualization/mpl_drawer.py | 0 .../visualization/style.py | 0 test/base.py | 2 +- 12 files changed, 83 insertions(+), 48 deletions(-) delete mode 100644 qiskit_experiments/curve_analysis/visualization/__init__.py create mode 100644 qiskit_experiments/visualization/__init__.py rename qiskit_experiments/{curve_analysis => }/visualization/base_drawer.py (100%) rename qiskit_experiments/{curve_analysis => }/visualization/curves.py (100%) rename qiskit_experiments/{curve_analysis => }/visualization/fit_result_plotters.py (100%) rename qiskit_experiments/{curve_analysis => }/visualization/mpl_drawer.py (100%) rename qiskit_experiments/{curve_analysis => }/visualization/style.py (100%) diff --git a/qiskit_experiments/curve_analysis/__init__.py b/qiskit_experiments/curve_analysis/__init__.py index 1eb7333dd6..8191b78bf4 100644 --- a/qiskit_experiments/curve_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/__init__.py @@ -481,15 +481,6 @@ def _create_analysis_results(self, fit_data, quality, **metadata): ParameterRepr FitOptions -Visualization -============= - -.. autosummary:: - :toctree: ../stubs/ - - BaseCurveDrawer - MplCurveDrawer - Standard Analysis Library ========================= @@ -565,7 +556,6 @@ def _create_analysis_results(self, fit_data, quality, **metadata): process_curve_data, process_multi_curve_data, ) -from .visualization import BaseCurveDrawer, MplCurveDrawer from . import guess from . import fit_function from . import utils @@ -580,6 +570,3 @@ def _create_analysis_results(self, fit_data, quality, **metadata): ErrorAmplificationAnalysis, BlochTrajectoryAnalysis, ) - -# deprecated -from .visualization import plot_curve_fit, plot_errorbar, plot_scatter, FitResultPlotters diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index 2ef78d3970..290b124155 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -24,7 +24,7 @@ from qiskit_experiments.data_processing.processor_library import get_processor from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData from .curve_data import CurveData, ParameterRepr, CurveFitResult -from .visualization import MplCurveDrawer, BaseCurveDrawer +from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer PARAMS_ENTRY_PREFIX = "@Parameters_" DATA_ENTRY_PREFIX = "@Data_" diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index 38a76b06f3..af24d9be12 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -25,7 +25,7 @@ from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties -from .visualization import MplCurveDrawer, BaseCurveDrawer +from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer class CompositeCurveAnalysis(BaseAnalysis): diff --git a/qiskit_experiments/curve_analysis/visualization/__init__.py b/qiskit_experiments/curve_analysis/visualization/__init__.py deleted file mode 100644 index 0c85169e32..0000000000 --- a/qiskit_experiments/curve_analysis/visualization/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2021. -# -# 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. -""" -Visualization functions -""" - -from enum import Enum - -from .base_drawer import BaseCurveDrawer -from .mpl_drawer import MplCurveDrawer - -from . import fit_result_plotters -from .curves import plot_scatter, plot_errorbar, plot_curve_fit -from .style import PlotterStyle - - -# pylint: disable=invalid-name -class FitResultPlotters(Enum): - """Map the plotter name to the plotters.""" - - mpl_single_canvas = fit_result_plotters.MplDrawSingleCanvas - mpl_multiv_canvas = fit_result_plotters.MplDrawMultiCanvasVstack diff --git a/qiskit_experiments/library/quantum_volume/qv_analysis.py b/qiskit_experiments/library/quantum_volume/qv_analysis.py index 94d3624db2..47c7124a0c 100644 --- a/qiskit_experiments/library/quantum_volume/qv_analysis.py +++ b/qiskit_experiments/library/quantum_volume/qv_analysis.py @@ -20,7 +20,7 @@ import numpy as np import uncertainties from qiskit_experiments.exceptions import AnalysisError -from qiskit_experiments.curve_analysis import plot_scatter, plot_errorbar +from qiskit_experiments.visualization import plot_scatter, plot_errorbar from qiskit_experiments.framework import ( BaseAnalysis, AnalysisResultData, diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py new file mode 100644 index 0000000000..49ed51a0fb --- /dev/null +++ b/qiskit_experiments/visualization/__init__.py @@ -0,0 +1,79 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +# r""" +# ========================================================= +# Visualization (:mod:`qiskit_experiments.visualization`) +# ========================================================= + +# .. currentmodule:: qiskit_experiments.visualization + +# Visualization provides plotting functionality for experiment results and analysis classes. This includes +# drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. + +# Drawer Library +# ============== + +# .. autosummary:: +# :toctree: ../stubs/ +# :template: autosummary/class.rst + +# BaseCurveDrawer +# MplCurveDrawer + +# Plotting Functions +# ================== + +# .. autosummary:: +# :toctree: ../stubs/ + +# plot_curve_fit +# plot_errorbar +# plot_scatter + +# Curve Fitting Helpers +# ===================== + +# .. autosummary:: +# :toctree: ../stubs/ +# :template: autosummary/class.rst + +# FitResultPlotters +# fit_result_plotters.MplDrawSingleCanvas +# fit_result_plotters.MplDrawMultiCanvasVstack + +# Plotting Style +# ============== + +# .. autosummary:: +# :toctree: ../stubs/ +# :template: autosummary/class.rst + +# PlotterStyle + +# """ + +from enum import Enum + +from .base_drawer import BaseCurveDrawer +from .mpl_drawer import MplCurveDrawer + +from . import fit_result_plotters +from .curves import plot_scatter, plot_errorbar, plot_curve_fit +from .style import PlotterStyle + + +# pylint: disable=invalid-name +class FitResultPlotters(Enum): + """Map the plotter name to the plotters.""" + + mpl_single_canvas = fit_result_plotters.MplDrawSingleCanvas + mpl_multiv_canvas = fit_result_plotters.MplDrawMultiCanvasVstack diff --git a/qiskit_experiments/curve_analysis/visualization/base_drawer.py b/qiskit_experiments/visualization/base_drawer.py similarity index 100% rename from qiskit_experiments/curve_analysis/visualization/base_drawer.py rename to qiskit_experiments/visualization/base_drawer.py diff --git a/qiskit_experiments/curve_analysis/visualization/curves.py b/qiskit_experiments/visualization/curves.py similarity index 100% rename from qiskit_experiments/curve_analysis/visualization/curves.py rename to qiskit_experiments/visualization/curves.py diff --git a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py b/qiskit_experiments/visualization/fit_result_plotters.py similarity index 100% rename from qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py rename to qiskit_experiments/visualization/fit_result_plotters.py diff --git a/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py b/qiskit_experiments/visualization/mpl_drawer.py similarity index 100% rename from qiskit_experiments/curve_analysis/visualization/mpl_drawer.py rename to qiskit_experiments/visualization/mpl_drawer.py diff --git a/qiskit_experiments/curve_analysis/visualization/style.py b/qiskit_experiments/visualization/style.py similarity index 100% rename from qiskit_experiments/curve_analysis/visualization/style.py rename to qiskit_experiments/visualization/style.py diff --git a/test/base.py b/test/base.py index d234bc4215..97800d6507 100644 --- a/test/base.py +++ b/test/base.py @@ -32,7 +32,7 @@ BaseExperiment, BaseAnalysis, ) -from qiskit_experiments.curve_analysis.visualization.base_drawer import BaseCurveDrawer +from qiskit_experiments.visualization import BaseCurveDrawer from qiskit_experiments.curve_analysis.curve_data import CurveFitResult From 95a2be4f2a1b2f9f6a0b81dfeda798c58b2b6688 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Wed, 7 Sep 2022 15:55:02 +0200 Subject: [PATCH 02/45] Split *CurveDrawer into *Drawer and *CurveDrawer --- qiskit_experiments/visualization/__init__.py | 83 +++++----- .../visualization/base_drawer.py | 143 ++++++++++-------- .../visualization/mpl_drawer.py | 80 +++++----- 3 files changed, 167 insertions(+), 139 deletions(-) diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 49ed51a0fb..4fafe7738b 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -9,62 +9,69 @@ # 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. -# r""" -# ========================================================= -# Visualization (:mod:`qiskit_experiments.visualization`) -# ========================================================= +r""" +========================================================= +Visualization (:mod:`qiskit_experiments.visualization`) +========================================================= -# .. currentmodule:: qiskit_experiments.visualization +.. currentmodule:: qiskit_experiments.visualization -# Visualization provides plotting functionality for experiment results and analysis classes. This includes -# drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. +Visualization provides plotting functionality for experiment results and analysis classes. This includes +drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. -# Drawer Library -# ============== +Drawer Library +============== -# .. autosummary:: -# :toctree: ../stubs/ -# :template: autosummary/class.rst +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst + BaseDrawer + BaseCurveDrawer -# BaseCurveDrawer -# MplCurveDrawer +Matplotlib Drawer Library +========================= +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst + MplDrawer + MplCurveDrawer -# Plotting Functions -# ================== +Plotting Functions +================== -# .. autosummary:: -# :toctree: ../stubs/ +.. autosummary:: + :toctree: ../stubs/ -# plot_curve_fit -# plot_errorbar -# plot_scatter + plot_curve_fit + plot_errorbar + plot_scatter -# Curve Fitting Helpers -# ===================== +Curve Fitting Helpers +===================== -# .. autosummary:: -# :toctree: ../stubs/ -# :template: autosummary/class.rst +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst -# FitResultPlotters -# fit_result_plotters.MplDrawSingleCanvas -# fit_result_plotters.MplDrawMultiCanvasVstack + FitResultPlotters + fit_result_plotters.MplDrawSingleCanvas + fit_result_plotters.MplDrawMultiCanvasVstack -# Plotting Style -# ============== +Plotting Style +============== -# .. autosummary:: -# :toctree: ../stubs/ -# :template: autosummary/class.rst +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst -# PlotterStyle + PlotterStyle -# """ +""" from enum import Enum -from .base_drawer import BaseCurveDrawer -from .mpl_drawer import MplCurveDrawer +from .base_drawer import BaseDrawer, BaseCurveDrawer +from .mpl_drawer import MplDrawer, MplCurveDrawer from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit diff --git a/qiskit_experiments/visualization/base_drawer.py b/qiskit_experiments/visualization/base_drawer.py index 2534663efe..13877660ea 100644 --- a/qiskit_experiments/visualization/base_drawer.py +++ b/qiskit_experiments/visualization/base_drawer.py @@ -18,12 +18,10 @@ from qiskit_experiments.framework import Options -class BaseCurveDrawer(ABC): - """Abstract class for the serializable Qiskit Experiments curve drawer. +class BaseDrawer(ABC): + """Abstract class for the serializable Qiskit Experiments drawer. - A curve drawer may be implemented by different drawing backends such as matplotlib - or plotly. Sub-classes that wrap these backends by subclassing `BaseCurveDrawer` must - implement the following abstract methods. + A drawer may be implemented by different drawing backends such as matplotlib or plotly. Sub-classes that wrap these backends by subclassing ``BaseDrawer`` must implement the following abstract methods. initialize_canvas @@ -57,32 +55,13 @@ class BaseCurveDrawer(ABC): The formatted data might be averaged over the same x values, or smoothed by a filtering algorithm, depending on how analysis class is implemented. This method is called with error bars of y values and the name of the curve. - - draw_fit_line - - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the interpolated x and y values. - - draw_confidence_interval - - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the interpolated x and a pair of y values - that represent the upper and lower bound within certain confidence interval. - This might be called multiple times with different interval sizes. - - draw_fit_report - - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the list of analysis results and the reduced chi-squared values. - The fit report should be generated to show this information on the canvas. - """ def __init__(self): self._options = self._default_options() self._set_options = set() self._axis = None - self._curves = list() + self._series = list() @property def options(self) -> Options: @@ -125,23 +104,12 @@ def _default_options(cls) -> Options: Horizontal position can be ``right``, ``center``, ``left``. tick_label_size (int): Size of text representing the axis tick numbers. axis_label_size (int): Size of text representing the axis label. - fit_report_rpos (Tuple[int, int]): A tuple of numbers showing the location of - the fit report window. These numbers are horizontal and vertical position - of the top left corner of the window in the relative coordinate - on the output figure, i.e. ``[0, 1]``. - The fit report window shows the selected fit parameters and the reduced - chi-squared value. - fit_report_text_size (int): Size of text in the fit report window. - plot_sigma (List[Tuple[float, float]]): A list of two number tuples - showing the configuration to write confidence intervals for the fit curve. - The first argument is the relative sigma (n_sigma), and the second argument is - the transparency of the interval plot in ``[0, 1]``. - Multiple n_sigma intervals can be drawn for the single curve. plot_options (Dict[str, Dict[str, Any]]): A dictionary of plot options for each curve. This is keyed on the model name for each curve. Sub-dictionary is expected to have following three configurations, "canvas", "color", and "symbol"; "canvas" is the integer index of axis (when multi-canvas plot is set), "color" is the color of the curve, and "symbol" is the marker style of the curve for scatter plots. + figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. """ return Options( @@ -157,9 +125,6 @@ def _default_options(cls) -> Options: legend_loc="center right", tick_label_size=14, axis_label_size=16, - fit_report_rpos=(0.6, 0.95), - fit_report_text_size=14, - plot_sigma=[(1.0, 0.7), (3.0, 0.3)], plot_options={}, figure_title=None, ) @@ -193,7 +158,7 @@ def draw_raw_data( Args: x_data: X values. y_data: Y values. - name: Name of this curve. + name: Name of this plot. options: Valid options for the drawer backend API. """ @@ -212,10 +177,83 @@ def draw_formatted_data( x_data: X values. y_data: Y values. y_err_data: Standard deviation of Y values. - name: Name of this curve. + name: Name of this plot. options: Valid options for the drawer backend API. """ + @property + @abstractmethod + def figure(self): + """Return figure object handler to be saved in the database.""" + + def config(self) -> Dict: + """Return the config dictionary for this drawing.""" + options = dict((key, getattr(self._options, key)) for key in self._set_options) + + return {"cls": type(self), "options": options} + + def __json_encode__(self): + return self.config() + + @classmethod + def __json_decode__(cls, value): + instance = cls() + if "options" in value: + instance.set_options(**value["options"]) + return instance + + +class BaseCurveDrawer(BaseDrawer): + """Abstract class for the serializable Qiskit Experiments curve drawer. + + A curve drawer may be implemented by different drawing backends such as matplotlib + or plotly, the same as :py:attr`BaseDrawer`. Sub-classes that wrap these backends by subclassing + `BaseCurveDrawer` must implement the following abstract methods over-and-above those mandated by + :py:class:`BaseDrawer`. + + draw_fit_line + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the interpolated x and y values. + + draw_confidence_interval + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the interpolated x and a pair of y values + that represent the upper and lower bound within certain confidence interval. + This might be called multiple times with different interval sizes. + + draw_fit_report + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the list of analysis results and the reduced chi-squared values. + The fit report should be generated to show this information on the canvas. + + """ + + @classmethod + def _default_options(cls) -> Options: + """Return default draw options. + Draw Options: + fit_report_rpos (Tuple[int, int]): A tuple of numbers showing the location of + the fit report window. These numbers are horizontal and vertical position + of the top left corner of the window in the relative coordinate + on the output figure, i.e. ``[0, 1]``. + The fit report window shows the selected fit parameters and the reduced + chi-squared value. + fit_report_text_size (int): Size of text in the fit report window. + plot_sigma (List[Tuple[float, float]]): A list of two number tuples + showing the configuration to write confidence intervals for the fit curve. + The first argument is the relative sigma (n_sigma), and the second argument is + the transparency of the interval plot in ``[0, 1]``. + Multiple n_sigma intervals can be drawn for the single curve. + """ + options = super()._default_options() + options.fit_report_rpos = (0.6, 0.95) + options.fit_report_text_size = 14 + options.plot_sigma = [(1.0, 0.7), (3.0, 0.3)] + return options + @abstractmethod def draw_fit_line( self, @@ -264,24 +302,3 @@ def draw_fit_report( description: A string to describe the fiting outcome. options: Valid options for the drawer backend API. """ - - @property - @abstractmethod - def figure(self): - """Return figure object handler to be saved in the database.""" - - def config(self) -> Dict: - """Return the config dictionary for this drawing.""" - options = dict((key, getattr(self._options, key)) for key in self._set_options) - - return {"cls": type(self), "options": options} - - def __json_encode__(self): - return self.config() - - @classmethod - def __json_decode__(cls, value): - instance = cls() - if "options" in value: - instance.set_options(**value["options"]) - return instance diff --git a/qiskit_experiments/visualization/mpl_drawer.py b/qiskit_experiments/visualization/mpl_drawer.py index 0605364cc7..86b001346a 100644 --- a/qiskit_experiments/visualization/mpl_drawer.py +++ b/qiskit_experiments/visualization/mpl_drawer.py @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Curve drawer for matplotlib backend.""" +"""Drawers for matplotlib backend.""" from typing import Sequence, Optional, Tuple @@ -24,11 +24,11 @@ from qiskit.utils import detach_prefix from qiskit_experiments.framework.matplotlib import get_non_gui_ax -from .base_drawer import BaseCurveDrawer +from .base_drawer import BaseDrawer, BaseCurveDrawer -class MplCurveDrawer(BaseCurveDrawer): - """Curve drawer for MatplotLib backend.""" +class MplDrawer(BaseDrawer): + """Drawer for MatplotLib backend.""" DefaultMarkers = MarkerStyle().filled_markers DefaultColors = tab10.colors @@ -161,7 +161,7 @@ def format_canvas(self): prefix = "" prefactor = 1 - formatter = MplCurveDrawer.PrefixFormatter(prefactor) + formatter = MplDrawer.PrefixFormatter(prefactor) units_str = f" [{prefix}{unit}]" else: # Use scientific notation with 3 digits, 1000 -> 1e3 @@ -204,6 +204,23 @@ def format_canvas(self): fontsize=self.options.axis_label_size, ) + @property + def figure(self) -> Figure: + """Return figure object handler to be saved in the database. + + In the MatplotLib the ``Figure`` and ``Axes`` are different object. + User can pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. + For example, a user wants to combine two different experiment results in the + same figure, one can call ``pyplot.subplots`` with two rows and pass one of the + generated two axes to each experiment drawer. Once all the experiments complete, + the user will obtain the single figure collecting all experimental results. + + Note that this method returns the entire figure object, rather than a single axis. + Thus, the experiment data saved in the database might have a figure + collecting all child axes drawings. + """ + return self._axis.get_figure() + def _get_axis(self, index: Optional[int] = None) -> Axes: """A helper method to get inset axis. @@ -228,33 +245,33 @@ def _get_axis(self, index: Optional[int] = None) -> Axes: return self._axis def _get_default_color(self, name: str) -> Tuple[float, ...]: - """A helper method to get default color for the curve. + """A helper method to get default color for the series. Args: - name: Name of the curve. + name: Name of the series. Returns: Default color available in matplotlib. """ - if name not in self._curves: - self._curves.append(name) + if name not in self._series: + self._series.append(name) - ind = self._curves.index(name) % len(self.DefaultColors) + ind = self._series.index(name) % len(self.DefaultColors) return self.DefaultColors[ind] def _get_default_marker(self, name: str) -> str: """A helper method to get default marker for the scatter plot. Args: - name: Name of the curve. + name: Name of the series. Returns: Default marker available in matplotlib. """ - if name not in self._curves: - self._curves.append(name) + if name not in self._series: + self._series.append(name) - ind = self._curves.index(name) % len(self.DefaultMarkers) + ind = self._series.index(name) % len(self.DefaultMarkers) return self.DefaultMarkers[ind] def draw_raw_data( @@ -264,9 +281,9 @@ def draw_raw_data( name: Optional[str] = None, **options, ): - curve_opts = self.options.plot_options.get(name, {}) - marker = curve_opts.get("symbol", self._get_default_marker(name)) - axis = curve_opts.get("canvas", None) + series_opts = self.options.plot_options.get(name, {}) + marker = series_opts.get("symbol", self._get_default_marker(name)) + axis = series_opts.get("canvas", None) draw_options = { "color": "grey", @@ -285,10 +302,10 @@ def draw_formatted_data( name: Optional[str] = None, **options, ): - curve_opts = self.options.plot_options.get(name, {}) - axis = curve_opts.get("canvas", None) - color = curve_opts.get("color", self._get_default_color(name)) - marker = curve_opts.get("symbol", self._get_default_marker(name)) + series_opts = self.options.plot_options.get(name, {}) + axis = series_opts.get("canvas", None) + color = series_opts.get("color", self._get_default_color(name)) + marker = series_opts.get("symbol", self._get_default_marker(name)) draw_ops = { "color": color, @@ -306,6 +323,10 @@ def draw_formatted_data( y_err_data = None self._get_axis(axis).errorbar(x_data, y_data, yerr=y_err_data, **draw_ops) + +class MplCurveDrawer(MplDrawer, BaseCurveDrawer): + """Curve drawer for MatplotLib backend.""" + def draw_fit_line( self, x_data: Sequence[float], @@ -370,20 +391,3 @@ def draw_fit_report( zorder=6, ) report_handler.set_bbox(bbox_props) - - @property - def figure(self) -> Figure: - """Return figure object handler to be saved in the database. - - In the MatplotLib the ``Figure`` and ``Axes`` are different object. - User can pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. - For example, a user wants to combine two different experiment results in the - same figure, one can call ``pyplot.subplots`` with two rows and pass one of the - generated two axes to each experiment drawer. Once all the experiments complete, - the user will obtain the single figure collecting all experimental results. - - Note that this method returns the entire figure object, rather than a single axis. - Thus, the experiment data saved in the database might have a figure - collecting all child axes drawings. - """ - return self._axis.get_figure() From ad6c73c1cab821d985dcfcb6bf3cafdf7303dd25 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 8 Sep 2022 10:18:04 +0200 Subject: [PATCH 03/45] Fix failing lint --- qiskit_experiments/curve_analysis/base_curve_analysis.py | 2 +- qiskit_experiments/curve_analysis/composite_curve_analysis.py | 2 +- qiskit_experiments/visualization/base_drawer.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index 290b124155..cf0feb0402 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -23,8 +23,8 @@ from qiskit_experiments.data_processing import DataProcessor from qiskit_experiments.data_processing.processor_library import get_processor from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData -from .curve_data import CurveData, ParameterRepr, CurveFitResult from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer +from .curve_data import CurveData, ParameterRepr, CurveFitResult PARAMS_ENTRY_PREFIX = "@Parameters_" DATA_ENTRY_PREFIX = "@Data_" diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index af24d9be12..d77f68c3a3 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -22,10 +22,10 @@ from uncertainties import unumpy as unp, UFloat from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options +from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties -from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer class CompositeCurveAnalysis(BaseAnalysis): diff --git a/qiskit_experiments/visualization/base_drawer.py b/qiskit_experiments/visualization/base_drawer.py index 13877660ea..a89c20505d 100644 --- a/qiskit_experiments/visualization/base_drawer.py +++ b/qiskit_experiments/visualization/base_drawer.py @@ -21,7 +21,8 @@ class BaseDrawer(ABC): """Abstract class for the serializable Qiskit Experiments drawer. - A drawer may be implemented by different drawing backends such as matplotlib or plotly. Sub-classes that wrap these backends by subclassing ``BaseDrawer`` must implement the following abstract methods. + A drawer may be implemented by different drawing backends such as matplotlib or plotly. Sub-classes + that wrap these backends by subclassing ``BaseDrawer`` must implement the following abstract methods. initialize_canvas From 6e6413836225d0b162fed11d2954feed646eb694 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Fri, 9 Sep 2022 14:38:54 +0200 Subject: [PATCH 04/45] Revert "Split *CurveDrawer into *Drawer and *CurveDrawer" This reverts commit 95a2be4f2a1b2f9f6a0b81dfeda798c58b2b6688. As per discussion in #902. --- qiskit_experiments/visualization/__init__.py | 83 +++++----- .../visualization/base_drawer.py | 144 ++++++++---------- .../visualization/mpl_drawer.py | 80 +++++----- 3 files changed, 139 insertions(+), 168 deletions(-) diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 4fafe7738b..49ed51a0fb 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -9,69 +9,62 @@ # 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. -r""" -========================================================= -Visualization (:mod:`qiskit_experiments.visualization`) -========================================================= +# r""" +# ========================================================= +# Visualization (:mod:`qiskit_experiments.visualization`) +# ========================================================= -.. currentmodule:: qiskit_experiments.visualization +# .. currentmodule:: qiskit_experiments.visualization -Visualization provides plotting functionality for experiment results and analysis classes. This includes -drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. +# Visualization provides plotting functionality for experiment results and analysis classes. This includes +# drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. -Drawer Library -============== +# Drawer Library +# ============== -.. autosummary:: - :toctree: ../stubs/ - :template: autosummary/class.rst - BaseDrawer - BaseCurveDrawer +# .. autosummary:: +# :toctree: ../stubs/ +# :template: autosummary/class.rst -Matplotlib Drawer Library -========================= -.. autosummary:: - :toctree: ../stubs/ - :template: autosummary/class.rst - MplDrawer - MplCurveDrawer +# BaseCurveDrawer +# MplCurveDrawer -Plotting Functions -================== +# Plotting Functions +# ================== -.. autosummary:: - :toctree: ../stubs/ +# .. autosummary:: +# :toctree: ../stubs/ - plot_curve_fit - plot_errorbar - plot_scatter +# plot_curve_fit +# plot_errorbar +# plot_scatter -Curve Fitting Helpers -===================== +# Curve Fitting Helpers +# ===================== -.. autosummary:: - :toctree: ../stubs/ - :template: autosummary/class.rst +# .. autosummary:: +# :toctree: ../stubs/ +# :template: autosummary/class.rst - FitResultPlotters - fit_result_plotters.MplDrawSingleCanvas - fit_result_plotters.MplDrawMultiCanvasVstack +# FitResultPlotters +# fit_result_plotters.MplDrawSingleCanvas +# fit_result_plotters.MplDrawMultiCanvasVstack -Plotting Style -============== +# Plotting Style +# ============== -.. autosummary:: - :toctree: ../stubs/ - :template: autosummary/class.rst +# .. autosummary:: +# :toctree: ../stubs/ +# :template: autosummary/class.rst - PlotterStyle +# PlotterStyle -""" +# """ from enum import Enum -from .base_drawer import BaseDrawer, BaseCurveDrawer -from .mpl_drawer import MplDrawer, MplCurveDrawer +from .base_drawer import BaseCurveDrawer +from .mpl_drawer import MplCurveDrawer from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit diff --git a/qiskit_experiments/visualization/base_drawer.py b/qiskit_experiments/visualization/base_drawer.py index a89c20505d..2534663efe 100644 --- a/qiskit_experiments/visualization/base_drawer.py +++ b/qiskit_experiments/visualization/base_drawer.py @@ -18,11 +18,12 @@ from qiskit_experiments.framework import Options -class BaseDrawer(ABC): - """Abstract class for the serializable Qiskit Experiments drawer. +class BaseCurveDrawer(ABC): + """Abstract class for the serializable Qiskit Experiments curve drawer. - A drawer may be implemented by different drawing backends such as matplotlib or plotly. Sub-classes - that wrap these backends by subclassing ``BaseDrawer`` must implement the following abstract methods. + A curve drawer may be implemented by different drawing backends such as matplotlib + or plotly. Sub-classes that wrap these backends by subclassing `BaseCurveDrawer` must + implement the following abstract methods. initialize_canvas @@ -56,13 +57,32 @@ class BaseDrawer(ABC): The formatted data might be averaged over the same x values, or smoothed by a filtering algorithm, depending on how analysis class is implemented. This method is called with error bars of y values and the name of the curve. + + draw_fit_line + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the interpolated x and y values. + + draw_confidence_interval + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the interpolated x and a pair of y values + that represent the upper and lower bound within certain confidence interval. + This might be called multiple times with different interval sizes. + + draw_fit_report + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the list of analysis results and the reduced chi-squared values. + The fit report should be generated to show this information on the canvas. + """ def __init__(self): self._options = self._default_options() self._set_options = set() self._axis = None - self._series = list() + self._curves = list() @property def options(self) -> Options: @@ -105,12 +125,23 @@ def _default_options(cls) -> Options: Horizontal position can be ``right``, ``center``, ``left``. tick_label_size (int): Size of text representing the axis tick numbers. axis_label_size (int): Size of text representing the axis label. + fit_report_rpos (Tuple[int, int]): A tuple of numbers showing the location of + the fit report window. These numbers are horizontal and vertical position + of the top left corner of the window in the relative coordinate + on the output figure, i.e. ``[0, 1]``. + The fit report window shows the selected fit parameters and the reduced + chi-squared value. + fit_report_text_size (int): Size of text in the fit report window. + plot_sigma (List[Tuple[float, float]]): A list of two number tuples + showing the configuration to write confidence intervals for the fit curve. + The first argument is the relative sigma (n_sigma), and the second argument is + the transparency of the interval plot in ``[0, 1]``. + Multiple n_sigma intervals can be drawn for the single curve. plot_options (Dict[str, Dict[str, Any]]): A dictionary of plot options for each curve. This is keyed on the model name for each curve. Sub-dictionary is expected to have following three configurations, "canvas", "color", and "symbol"; "canvas" is the integer index of axis (when multi-canvas plot is set), "color" is the color of the curve, and "symbol" is the marker style of the curve for scatter plots. - figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. """ return Options( @@ -126,6 +157,9 @@ def _default_options(cls) -> Options: legend_loc="center right", tick_label_size=14, axis_label_size=16, + fit_report_rpos=(0.6, 0.95), + fit_report_text_size=14, + plot_sigma=[(1.0, 0.7), (3.0, 0.3)], plot_options={}, figure_title=None, ) @@ -159,7 +193,7 @@ def draw_raw_data( Args: x_data: X values. y_data: Y values. - name: Name of this plot. + name: Name of this curve. options: Valid options for the drawer backend API. """ @@ -178,83 +212,10 @@ def draw_formatted_data( x_data: X values. y_data: Y values. y_err_data: Standard deviation of Y values. - name: Name of this plot. + name: Name of this curve. options: Valid options for the drawer backend API. """ - @property - @abstractmethod - def figure(self): - """Return figure object handler to be saved in the database.""" - - def config(self) -> Dict: - """Return the config dictionary for this drawing.""" - options = dict((key, getattr(self._options, key)) for key in self._set_options) - - return {"cls": type(self), "options": options} - - def __json_encode__(self): - return self.config() - - @classmethod - def __json_decode__(cls, value): - instance = cls() - if "options" in value: - instance.set_options(**value["options"]) - return instance - - -class BaseCurveDrawer(BaseDrawer): - """Abstract class for the serializable Qiskit Experiments curve drawer. - - A curve drawer may be implemented by different drawing backends such as matplotlib - or plotly, the same as :py:attr`BaseDrawer`. Sub-classes that wrap these backends by subclassing - `BaseCurveDrawer` must implement the following abstract methods over-and-above those mandated by - :py:class:`BaseDrawer`. - - draw_fit_line - - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the interpolated x and y values. - - draw_confidence_interval - - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the interpolated x and a pair of y values - that represent the upper and lower bound within certain confidence interval. - This might be called multiple times with different interval sizes. - - draw_fit_report - - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the list of analysis results and the reduced chi-squared values. - The fit report should be generated to show this information on the canvas. - - """ - - @classmethod - def _default_options(cls) -> Options: - """Return default draw options. - Draw Options: - fit_report_rpos (Tuple[int, int]): A tuple of numbers showing the location of - the fit report window. These numbers are horizontal and vertical position - of the top left corner of the window in the relative coordinate - on the output figure, i.e. ``[0, 1]``. - The fit report window shows the selected fit parameters and the reduced - chi-squared value. - fit_report_text_size (int): Size of text in the fit report window. - plot_sigma (List[Tuple[float, float]]): A list of two number tuples - showing the configuration to write confidence intervals for the fit curve. - The first argument is the relative sigma (n_sigma), and the second argument is - the transparency of the interval plot in ``[0, 1]``. - Multiple n_sigma intervals can be drawn for the single curve. - """ - options = super()._default_options() - options.fit_report_rpos = (0.6, 0.95) - options.fit_report_text_size = 14 - options.plot_sigma = [(1.0, 0.7), (3.0, 0.3)] - return options - @abstractmethod def draw_fit_line( self, @@ -303,3 +264,24 @@ def draw_fit_report( description: A string to describe the fiting outcome. options: Valid options for the drawer backend API. """ + + @property + @abstractmethod + def figure(self): + """Return figure object handler to be saved in the database.""" + + def config(self) -> Dict: + """Return the config dictionary for this drawing.""" + options = dict((key, getattr(self._options, key)) for key in self._set_options) + + return {"cls": type(self), "options": options} + + def __json_encode__(self): + return self.config() + + @classmethod + def __json_decode__(cls, value): + instance = cls() + if "options" in value: + instance.set_options(**value["options"]) + return instance diff --git a/qiskit_experiments/visualization/mpl_drawer.py b/qiskit_experiments/visualization/mpl_drawer.py index 86b001346a..0605364cc7 100644 --- a/qiskit_experiments/visualization/mpl_drawer.py +++ b/qiskit_experiments/visualization/mpl_drawer.py @@ -10,7 +10,7 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Drawers for matplotlib backend.""" +"""Curve drawer for matplotlib backend.""" from typing import Sequence, Optional, Tuple @@ -24,11 +24,11 @@ from qiskit.utils import detach_prefix from qiskit_experiments.framework.matplotlib import get_non_gui_ax -from .base_drawer import BaseDrawer, BaseCurveDrawer +from .base_drawer import BaseCurveDrawer -class MplDrawer(BaseDrawer): - """Drawer for MatplotLib backend.""" +class MplCurveDrawer(BaseCurveDrawer): + """Curve drawer for MatplotLib backend.""" DefaultMarkers = MarkerStyle().filled_markers DefaultColors = tab10.colors @@ -161,7 +161,7 @@ def format_canvas(self): prefix = "" prefactor = 1 - formatter = MplDrawer.PrefixFormatter(prefactor) + formatter = MplCurveDrawer.PrefixFormatter(prefactor) units_str = f" [{prefix}{unit}]" else: # Use scientific notation with 3 digits, 1000 -> 1e3 @@ -204,23 +204,6 @@ def format_canvas(self): fontsize=self.options.axis_label_size, ) - @property - def figure(self) -> Figure: - """Return figure object handler to be saved in the database. - - In the MatplotLib the ``Figure`` and ``Axes`` are different object. - User can pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. - For example, a user wants to combine two different experiment results in the - same figure, one can call ``pyplot.subplots`` with two rows and pass one of the - generated two axes to each experiment drawer. Once all the experiments complete, - the user will obtain the single figure collecting all experimental results. - - Note that this method returns the entire figure object, rather than a single axis. - Thus, the experiment data saved in the database might have a figure - collecting all child axes drawings. - """ - return self._axis.get_figure() - def _get_axis(self, index: Optional[int] = None) -> Axes: """A helper method to get inset axis. @@ -245,33 +228,33 @@ def _get_axis(self, index: Optional[int] = None) -> Axes: return self._axis def _get_default_color(self, name: str) -> Tuple[float, ...]: - """A helper method to get default color for the series. + """A helper method to get default color for the curve. Args: - name: Name of the series. + name: Name of the curve. Returns: Default color available in matplotlib. """ - if name not in self._series: - self._series.append(name) + if name not in self._curves: + self._curves.append(name) - ind = self._series.index(name) % len(self.DefaultColors) + ind = self._curves.index(name) % len(self.DefaultColors) return self.DefaultColors[ind] def _get_default_marker(self, name: str) -> str: """A helper method to get default marker for the scatter plot. Args: - name: Name of the series. + name: Name of the curve. Returns: Default marker available in matplotlib. """ - if name not in self._series: - self._series.append(name) + if name not in self._curves: + self._curves.append(name) - ind = self._series.index(name) % len(self.DefaultMarkers) + ind = self._curves.index(name) % len(self.DefaultMarkers) return self.DefaultMarkers[ind] def draw_raw_data( @@ -281,9 +264,9 @@ def draw_raw_data( name: Optional[str] = None, **options, ): - series_opts = self.options.plot_options.get(name, {}) - marker = series_opts.get("symbol", self._get_default_marker(name)) - axis = series_opts.get("canvas", None) + curve_opts = self.options.plot_options.get(name, {}) + marker = curve_opts.get("symbol", self._get_default_marker(name)) + axis = curve_opts.get("canvas", None) draw_options = { "color": "grey", @@ -302,10 +285,10 @@ def draw_formatted_data( name: Optional[str] = None, **options, ): - series_opts = self.options.plot_options.get(name, {}) - axis = series_opts.get("canvas", None) - color = series_opts.get("color", self._get_default_color(name)) - marker = series_opts.get("symbol", self._get_default_marker(name)) + curve_opts = self.options.plot_options.get(name, {}) + axis = curve_opts.get("canvas", None) + color = curve_opts.get("color", self._get_default_color(name)) + marker = curve_opts.get("symbol", self._get_default_marker(name)) draw_ops = { "color": color, @@ -323,10 +306,6 @@ def draw_formatted_data( y_err_data = None self._get_axis(axis).errorbar(x_data, y_data, yerr=y_err_data, **draw_ops) - -class MplCurveDrawer(MplDrawer, BaseCurveDrawer): - """Curve drawer for MatplotLib backend.""" - def draw_fit_line( self, x_data: Sequence[float], @@ -391,3 +370,20 @@ def draw_fit_report( zorder=6, ) report_handler.set_bbox(bbox_props) + + @property + def figure(self) -> Figure: + """Return figure object handler to be saved in the database. + + In the MatplotLib the ``Figure`` and ``Axes`` are different object. + User can pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. + For example, a user wants to combine two different experiment results in the + same figure, one can call ``pyplot.subplots`` with two rows and pass one of the + generated two axes to each experiment drawer. Once all the experiments complete, + the user will obtain the single figure collecting all experimental results. + + Note that this method returns the entire figure object, rather than a single axis. + Thus, the experiment data saved in the database might have a figure + collecting all child axes drawings. + """ + return self._axis.get_figure() From fc22ff145a290fce2e100e60afa40a382d0b2af9 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 12 Sep 2022 10:23:34 +0200 Subject: [PATCH 05/45] Move drawers to .visualization.drawers submodule --- qiskit_experiments/visualization/__init__.py | 3 +-- .../visualization/drawers/__init__.py | 15 +++++++++++++++ .../visualization/{ => drawers}/base_drawer.py | 0 .../visualization/{ => drawers}/mpl_drawer.py | 0 4 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 qiskit_experiments/visualization/drawers/__init__.py rename qiskit_experiments/visualization/{ => drawers}/base_drawer.py (100%) rename qiskit_experiments/visualization/{ => drawers}/mpl_drawer.py (100%) diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 49ed51a0fb..9e2d834ff3 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -63,8 +63,7 @@ from enum import Enum -from .base_drawer import BaseCurveDrawer -from .mpl_drawer import MplCurveDrawer +from .drawers import BaseCurveDrawer, MplCurveDrawer from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit diff --git a/qiskit_experiments/visualization/drawers/__init__.py b/qiskit_experiments/visualization/drawers/__init__.py new file mode 100644 index 0000000000..274642e254 --- /dev/null +++ b/qiskit_experiments/visualization/drawers/__init__.py @@ -0,0 +1,15 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +"""Drawers submodule, defining interfaces to figure backends.""" + +from .base_drawer import BaseCurveDrawer +from .mpl_drawer import MplCurveDrawer diff --git a/qiskit_experiments/visualization/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py similarity index 100% rename from qiskit_experiments/visualization/base_drawer.py rename to qiskit_experiments/visualization/drawers/base_drawer.py diff --git a/qiskit_experiments/visualization/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py similarity index 100% rename from qiskit_experiments/visualization/mpl_drawer.py rename to qiskit_experiments/visualization/drawers/mpl_drawer.py From fb744108a120bade4d5fd38d910c742e725614bc Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 12 Sep 2022 10:32:41 +0200 Subject: [PATCH 06/45] Rename CurveDrawer to Drawer --- qiskit_experiments/curve_analysis/__init__.py | 2 +- .../curve_analysis/base_curve_analysis.py | 10 +++++----- .../curve_analysis/composite_curve_analysis.py | 10 +++++----- qiskit_experiments/visualization/__init__.py | 6 +++--- qiskit_experiments/visualization/drawers/__init__.py | 4 ++-- .../visualization/drawers/base_drawer.py | 4 ++-- qiskit_experiments/visualization/drawers/mpl_drawer.py | 6 +++--- test/base.py | 4 ++-- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/qiskit_experiments/curve_analysis/__init__.py b/qiskit_experiments/curve_analysis/__init__.py index 8191b78bf4..8bb2b77126 100644 --- a/qiskit_experiments/curve_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/__init__.py @@ -319,7 +319,7 @@ class AnalysisB(CurveAnalysis): See :ref:`curve_analysis_results` for details. Afterwards, the analysis draws several curves in the Matplotlib figure. User can set custom drawer to the option ``curve_drawer``. -The drawer defaults to the :class:`MplCurveDrawer`. +The drawer defaults to the :class:`MplDrawer`. Finally, it returns the list of created analysis results and Matplotlib figure. diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index cf0feb0402..a7070e06e5 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -23,7 +23,7 @@ from qiskit_experiments.data_processing import DataProcessor from qiskit_experiments.data_processing.processor_library import get_processor from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData -from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer +from qiskit_experiments.visualization import MplDrawer, BaseDrawer from .curve_data import CurveData, ParameterRepr, CurveFitResult PARAMS_ENTRY_PREFIX = "@Parameters_" @@ -113,7 +113,7 @@ def models(self) -> List[lmfit.Model]: """Return fit models.""" @property - def drawer(self) -> BaseCurveDrawer: + def drawer(self) -> BaseDrawer: """A short-cut for curve drawer instance.""" return self._options.curve_drawer @@ -122,7 +122,7 @@ def _default_options(cls) -> Options: """Return default analysis options. Analysis Options: - curve_drawer (BaseCurveDrawer): A curve drawer instance to visualize + curve_drawer (BaseDrawer): A curve drawer instance to visualize the analysis result. plot_raw_data (bool): Set ``True`` to draw processed data points, dataset without formatting, on canvas. This is ``False`` by default. @@ -168,7 +168,7 @@ def _default_options(cls) -> Options: """ options = super()._default_options() - options.curve_drawer = MplCurveDrawer() + options.curve_drawer = MplDrawer() options.plot_raw_data = False options.plot = True options.return_fit_parameters = True @@ -187,7 +187,7 @@ def _default_options(cls) -> Options: # Set automatic validator for particular option values options.set_validator(field="data_processor", validator_value=DataProcessor) - options.set_validator(field="curve_drawer", validator_value=BaseCurveDrawer) + options.set_validator(field="curve_drawer", validator_value=BaseDrawer) return options diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index d77f68c3a3..a1c1c5663f 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -22,7 +22,7 @@ from uncertainties import unumpy as unp, UFloat from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options -from qiskit_experiments.visualization import MplCurveDrawer, BaseCurveDrawer +from qiskit_experiments.visualization import MplDrawer, BaseDrawer from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties @@ -124,7 +124,7 @@ def models(self) -> Dict[str, List[lmfit.Model]]: return models @property - def drawer(self) -> BaseCurveDrawer: + def drawer(self) -> BaseDrawer: """A short-cut for curve drawer instance.""" return self._options.curve_drawer @@ -187,7 +187,7 @@ def _default_options(cls) -> Options: """Default analysis options. Analysis Options: - curve_drawer (BaseCurveDrawer): A curve drawer instance to visualize + curve_drawer (BaseDrawer): A curve drawer instance to visualize the analysis result. plot (bool): Set ``True`` to create figure for fit result. This is ``True`` by default. @@ -200,7 +200,7 @@ def _default_options(cls) -> Options: """ options = super()._default_options() options.update_options( - curve_drawer=MplCurveDrawer(), + curve_drawer=MplDrawer(), plot=True, return_fit_parameters=True, return_data_points=False, @@ -208,7 +208,7 @@ def _default_options(cls) -> Options: ) # Set automatic validator for particular option values - options.set_validator(field="curve_drawer", validator_value=BaseCurveDrawer) + options.set_validator(field="curve_drawer", validator_value=BaseDrawer) return options diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 9e2d834ff3..0292d9e7e7 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -26,8 +26,8 @@ # :toctree: ../stubs/ # :template: autosummary/class.rst -# BaseCurveDrawer -# MplCurveDrawer +# BaseDrawer +# MplDrawer # Plotting Functions # ================== @@ -63,7 +63,7 @@ from enum import Enum -from .drawers import BaseCurveDrawer, MplCurveDrawer +from .drawers import BaseDrawer, MplDrawer from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit diff --git a/qiskit_experiments/visualization/drawers/__init__.py b/qiskit_experiments/visualization/drawers/__init__.py index 274642e254..6f3a37504c 100644 --- a/qiskit_experiments/visualization/drawers/__init__.py +++ b/qiskit_experiments/visualization/drawers/__init__.py @@ -11,5 +11,5 @@ # that they have been altered from the originals. """Drawers submodule, defining interfaces to figure backends.""" -from .base_drawer import BaseCurveDrawer -from .mpl_drawer import MplCurveDrawer +from .base_drawer import BaseDrawer +from .mpl_drawer import MplDrawer diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 2534663efe..95e582582f 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -18,11 +18,11 @@ from qiskit_experiments.framework import Options -class BaseCurveDrawer(ABC): +class BaseDrawer(ABC): """Abstract class for the serializable Qiskit Experiments curve drawer. A curve drawer may be implemented by different drawing backends such as matplotlib - or plotly. Sub-classes that wrap these backends by subclassing `BaseCurveDrawer` must + or plotly. Sub-classes that wrap these backends by subclassing `BaseDrawer` must implement the following abstract methods. initialize_canvas diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 0605364cc7..5bd8a33091 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -24,10 +24,10 @@ from qiskit.utils import detach_prefix from qiskit_experiments.framework.matplotlib import get_non_gui_ax -from .base_drawer import BaseCurveDrawer +from .base_drawer import BaseDrawer -class MplCurveDrawer(BaseCurveDrawer): +class MplDrawer(BaseDrawer): """Curve drawer for MatplotLib backend.""" DefaultMarkers = MarkerStyle().filled_markers @@ -161,7 +161,7 @@ def format_canvas(self): prefix = "" prefactor = 1 - formatter = MplCurveDrawer.PrefixFormatter(prefactor) + formatter = MplDrawer.PrefixFormatter(prefactor) units_str = f" [{prefix}{unit}]" else: # Use scientific notation with 3 digits, 1000 -> 1e3 diff --git a/test/base.py b/test/base.py index 97800d6507..301f1a56bd 100644 --- a/test/base.py +++ b/test/base.py @@ -32,7 +32,7 @@ BaseExperiment, BaseAnalysis, ) -from qiskit_experiments.visualization import BaseCurveDrawer +from qiskit_experiments.visualization import BaseDrawer from qiskit_experiments.curve_analysis.curve_data import CurveFitResult @@ -109,7 +109,7 @@ def assertRoundTripPickle(self, obj: Any, check_func: Optional[Callable] = None) def json_equiv(cls, data1, data2) -> bool: """Check if two experiments are equivalent by comparing their configs""" # pylint: disable = too-many-return-statements - configurable_type = (BaseExperiment, BaseAnalysis, BaseCurveDrawer) + configurable_type = (BaseExperiment, BaseAnalysis, BaseDrawer) compare_repr = (DataAction, DataProcessor) list_type = (list, tuple, set) skipped = tuple() From 451a6ca77cf5cb27afa15b998725d1284e8955f7 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Wed, 14 Sep 2022 08:59:46 +0200 Subject: [PATCH 07/45] Add plotters to visualization submodule --- qiskit_experiments/visualization/__init__.py | 1 + .../visualization/drawers/base_drawer.py | 3 +- .../visualization/plotters/__init__.py | 15 ++ .../visualization/plotters/base_plotter.py | 249 ++++++++++++++++++ .../visualization/plotters/curve_plotter.py | 75 ++++++ 5 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 qiskit_experiments/visualization/plotters/__init__.py create mode 100644 qiskit_experiments/visualization/plotters/base_plotter.py create mode 100644 qiskit_experiments/visualization/plotters/curve_plotter.py diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 0292d9e7e7..839ba5f242 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -64,6 +64,7 @@ from enum import Enum from .drawers import BaseDrawer, MplDrawer +from .plotters import BasePlotter,CurvePlotter from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 95e582582f..12dfc95124 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2022. +# (C) Copyright IBM 2021, 2022. # # 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 @@ -159,7 +159,6 @@ def _default_options(cls) -> Options: axis_label_size=16, fit_report_rpos=(0.6, 0.95), fit_report_text_size=14, - plot_sigma=[(1.0, 0.7), (3.0, 0.3)], plot_options={}, figure_title=None, ) diff --git a/qiskit_experiments/visualization/plotters/__init__.py b/qiskit_experiments/visualization/plotters/__init__.py new file mode 100644 index 0000000000..c8f2133501 --- /dev/null +++ b/qiskit_experiments/visualization/plotters/__init__.py @@ -0,0 +1,15 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +"""Plotters submodule, defining interfaces to draw figures.""" + +from .base_plotter import BasePlotter +from .curve_plotter import CurvePlotter diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py new file mode 100644 index 0000000000..0c2a95f488 --- /dev/null +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -0,0 +1,249 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +"""Base plotter abstract class""" + +from abc import ABC, abstractmethod +from typing import Any, Iterable, Union, Optional, List, Tuple, Dict +from qiskit_experiments.framework import Options +from qiskit_experiments.visualization.drawers import BaseDrawer + + +class BasePlotter(ABC): + """An abstract class for the serializable figure plotters. + + A plotter takes data from an experiment analysis class and plots a given figure using a drawing + backend. Sub-classes define the kind of figure created. + + Data is grouped into series, identified by a series name (str). There can be multiple different sets + of data associated with a given series name, which are identified by a data key (str). Experimental + and analysis results can be passed to the plotter so appropriate graphics can be drawn on the figure + canvas. Adding data is done through :meth:`set_series_data` and :meth:`set_figure_data`, with + querying done through the :meth:`data_for` and :meth:`data_exists_for` methods. + + There are two types of data associated with a plotter: series and figure data. The former is a + dataset of values to be plotted on a canvas, such that the data can be grouped into subsets + identified by their series name. Series names can be thought of as legend labels for the plotted + data. Figure data is not associated with a series and is instead only associated with the figure. + Examples include analysis reports or other text that is drawn onto the figure canvas. + """ + + def __init__(self, drawer: BaseDrawer): + """Create a new plotter instance. + + Args: + drawer: The drawer to use when creating the figure. + """ + self._series_data: Dict[str, Dict[str, Any]] = {} + self._figure_data: Dict[str, Any] = {} + self._options = self._default_options() + self._set_options = set() + self._drawer = drawer + + @property + def drawer(self) -> BaseDrawer: + """The drawer being used by the plotter.""" + return self._drawer + + @drawer.setter + def drawer(self, new_drawer: BaseDrawer): + """Set the drawer to be used by the plotter.""" + self._drawer = new_drawer + + @property + def figure_data(self) -> Dict[str, Any]: + return self._figure_data + + @property + def series_data(self) -> Dict[str, Dict[str, Any]]: + return self._series_data + + @property + def series(self) -> List[str]: + """The series names for this plotter.""" + return list(self._series_data.keys()) + + def data_keys_for(self, series_name: str) -> List[str]: + """Returns a list of data-keys for the given series. + + Args: + series_name: The series name for the given series. + + Returns: + list: The list of data-keys for data in the plotter associated with the given series. If the + series has not been added to the plotter, an empty list is returned. + """ + if series_name not in self._series_data: + return [] + return list(self._series_data[series_name]) + + def data_for(self, series_name: str, data_keys: Union[str, List[str]]) -> Tuple[Optional[Any]]: + """Returns data associated with the given series. + + The returned tuple contains the data, associated with ``data_keys``, in the same orders as they are provided. For example, + + .. code-example::python + plotter.set_series_data("seriesA", x=data.x, y=data.y, yerr=data.yerr) + + # The following calls are equivalent. + x, y, yerr = plotter.series_data_for("seriesA", ["x", "y", "yerr"]) + x, y, yerr = data.x, data.y, data.yerr + + :meth:`series_data_for` is intended to be used by sub-classes of :class:`BasePlotter` when + plotting in :meth:`_plot_figure`. + + Args: + series_name: The series name for the given series. + data_keys: List of data-keys for the data to be returned. + + Returns: + tuple: A tuple of data associated with the given series, identified by ``data_keys``. + """ + + # We may be given a single data-key, but we need an iterable for the rest of the function. + if not isinstance(data_keys, list): + data_keys = [data_keys] + + # The series doesn't exist in the plotter data, return None for each data-key in the output. + if series_name not in self._series_data: + return (None,) * len(data_keys) + + return (self._series_data[series_name].get(key, None) for key in data_keys) + + def set_series_data(self, series_name: str, **data_kwargs): + """Sets data for the given series. + + Note that if data has already been assigned for the given series and data-key, it will be + overridden by the new values. + + Args: + series_name: The name of the given series. + data_kwargs: The data to be added, where the keyword is the data-key. + """ + if series_name not in self._series_data: + self._series_data[series_name] = {} + self._series_data[series_name].update(**data_kwargs) + + def clear_series_data(self, series_name: Optional[str] = None): + """Clear series data for this plotter. + + Args: + series_name: The series name identifying which data should be cleared. If None, all series + data is cleared. Defaults to None. + """ + if series_name is None: + self._series_data = {} + elif series_name in self._series_data: + self._series_data.pop(series_name) + + def set_figure_data(self, **data_kwargs): + """Sets data for the entire figure. + + Figure data differs from series data in that it is not associate with a series name. Fit reports + are examples of figure data as they are drawn on figures to report on analysis results and the + "goodness" of a curve-fit, not on the specific of a given line, point, or shape drawn on the + figure canvas. + """ + self._figure_data.update(**data_kwargs) + + def clear_figure_data(self): + """Clears figure data.""" + self._figure_data = {} + + def data_exists_for(self, series_name: str, data_keys: Union[str, List[str]]) -> bool: + """Returns whether the given data-keys exist for the given series. + + Args: + series_name: The name of the given series. + data_keys: The data-keys to be checked. + + Returns: + bool: True if all data-keys have values assigned for the given series. False if at least one + does not have a value assigned. + """ + if not isinstance(data_keys, list): + data_keys = [data_keys] + + # Handle non-existent series name + if series_name not in self._series_data: + return False + + return all([key in self._series_data[series_name] for key in data_keys]) + + @abstractmethod + def _plot_figure(self): + """Generates a figure using :attr:`drawer` and :meth:`data`. + + Sub-classes must override this function to plot data using the drawer. This function is called by + :meth:`figure`. + """ + + def figure(self) -> Any: + """Generates and returns a figure for the already provided data. + + :meth:`figure` calls :meth:`_plot_figure`, which is overridden by sub-classes. Before and after calling :meth:`_plot_figure`, :func:`initialize_canvas` and :func:`format_canvas` are called on the drawer respectively. + + Returns: + Any: A figure generated by :attr:`drawer`. + """ + self.drawer.initialize_canvas() + self._plot_figure() + self.drawer.format_canvas() + return self.drawer.figure + + @property + def options(self) -> Options: + return self._options + + @classmethod + @abstractmethod + def _default_series_data_keys(cls) -> List[str]: + """Returns the default series data-keys supported by this plotter. + + Returns: + list: List of data-keys. + """ + # TODO: This function is meant to be similar to _default_options, so that data-keys are defined somewhere. Not sure if this is the best way of doing it. + + @classmethod + def _default_options(cls) -> Options: + """Return default plotting options.""" + return Options() + + def set_options(self, **fields): + """Set the plotter options. + + Args: + fields: The fields to update the options. + """ + self._options.update_options(**fields) + self._set_options = self._set_options.union(fields) + + def config(self) -> Dict: + """Return the config dictionary for this drawing.""" + # TODO: Figure out how self._drawer:BaseDrawer be serialized? + options = dict((key, getattr(self._options, key)) for key in self._set_options) + + return { + "cls": type(self), + "options": options, + } + + def __json_encode__(self): + return self.config() + + @classmethod + def __json_decode__(cls, value): + # TODO: Figure out how self._drawer:BaseDrawer be serialized? + instance = cls() + if "options" in value: + instance.set_options(**value["options"]) + return instance diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py new file mode 100644 index 0000000000..e8b8c70741 --- /dev/null +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -0,0 +1,75 @@ +from typing import List + +from qiskit_experiments.framework import Options +from qiskit_experiments.visualization import BaseDrawer + +from .base_plotter import BasePlotter + + +class CurvePlotter(BasePlotter): + def __init__(self, drawer: BaseDrawer): + super().__init__(drawer) + + @classmethod + def _default_series_data_keys(cls) -> List[str]: + return [ + "x", + "y", + "x_formatted", + "y_formatted", + "y_formatted_err", + "x_interp", + "y_mean", + "sigmas", + "fit_report", + ] + + @classmethod + def _default_options(cls) -> Options: + """Return curve-plotter specific default plotting options. + + Plot Options: + plot_sigma (List[Tuple[float, float]]): A list of two number tuples + showing the configuration to write confidence intervals for the fit curve. + The first argument is the relative sigma (n_sigma), and the second argument is + the transparency of the interval plot in ``[0, 1]``. + Multiple n_sigma intervals can be drawn for the single curve. + + """ + options = super()._default_options() + options.plot_sigma = [(1.0, 0.7), (3.0, 0.3)] + return options + + def _plot_figure(self): + for ser in self.series: + # Scatter plot + if self.data_exists_for(ser, ["x", "y"]): + x, y = self.data_for(ser, ["x", "y"]) + self.drawer.draw_raw_data(x, y, ser) + + # Scatter plot with error-bars + if self.data_exists_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]): + x, y, yerr = self.data_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]) + self.drawer.draw_formatted_data(x, y, yerr, ser) + + # Line plot for fit + if self.data_exists_for(ser, ["x_interp", "y_mean"]): + x, y = self.data_for(ser, ["x_interp", "y_mean"]) + self.drawer.draw_fit_line(x, y, ser) + + # Confidence interval plot + if self.data_exists_for(ser, ["x_interp", "y_mean", "sigmas"]): + x, y_mean, sigmas = self.data_for(ser, ["x_interp", "y_mean", "sigmas"]) + for n_sigma, alpha in self.options.plot_sigma: + self.drawer.draw_confidence_interval( + x, + y_mean + n_sigma * sigmas, + y_mean - n_sigma * sigmas, + ser, + alpha=alpha, + ) + + # Fit report + if "fit_report" in self.figure_data: + fit_report_description = self.figure_data["fit_report"] + self.drawer.draw_fit_report(fit_report_description) From f63b2ce9c3c08ec5c3c126a2e57516538a490aa7 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Wed, 14 Sep 2022 09:00:45 +0200 Subject: [PATCH 08/45] Replace drawer usage with plotter --- qiskit_experiments/curve_analysis/__init__.py | 4 +- .../curve_analysis/base_curve_analysis.py | 14 ++--- .../composite_curve_analysis.py | 59 ++++++++----------- .../curve_analysis/curve_analysis.py | 49 +++++++-------- .../standard_analysis/bloch_trajectory.py | 2 +- .../error_amplification_analysis.py | 2 +- .../standard_analysis/gaussian.py | 2 +- .../standard_analysis/resonance.py | 2 +- .../analysis/cr_hamiltonian_analysis.py | 2 +- .../analysis/drag_analysis.py | 2 +- .../analysis/ramsey_xy_analysis.py | 2 +- .../resonator_spectroscopy_analysis.py | 8 +-- .../characterization/analysis/t1_analysis.py | 4 +- .../analysis/t2hahn_analysis.py | 2 +- .../analysis/t2ramsey_analysis.py | 2 +- .../randomized_benchmarking/rb_analysis.py | 2 +- test/curve_analysis/test_baseclass.py | 2 +- 17 files changed, 73 insertions(+), 87 deletions(-) diff --git a/qiskit_experiments/curve_analysis/__init__.py b/qiskit_experiments/curve_analysis/__init__.py index 8bb2b77126..1b5dcb4b95 100644 --- a/qiskit_experiments/curve_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/__init__.py @@ -318,8 +318,8 @@ class AnalysisB(CurveAnalysis): compute custom quantities based on the raw fit parameters. See :ref:`curve_analysis_results` for details. Afterwards, the analysis draws several curves in the Matplotlib figure. -User can set custom drawer to the option ``curve_drawer``. -The drawer defaults to the :class:`MplDrawer`. +User can set custom plotter to the option ``plotter``. +The plotter defaults to the :class:`CurvePlotter`. Finally, it returns the list of created analysis results and Matplotlib figure. diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index a7070e06e5..b0fa8de5e3 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -23,7 +23,7 @@ from qiskit_experiments.data_processing import DataProcessor from qiskit_experiments.data_processing.processor_library import get_processor from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData -from qiskit_experiments.visualization import MplDrawer, BaseDrawer +from qiskit_experiments.visualization import MplDrawer, BaseDrawer, BasePlotter, CurvePlotter from .curve_data import CurveData, ParameterRepr, CurveFitResult PARAMS_ENTRY_PREFIX = "@Parameters_" @@ -113,16 +113,16 @@ def models(self) -> List[lmfit.Model]: """Return fit models.""" @property - def drawer(self) -> BaseDrawer: - """A short-cut for curve drawer instance.""" - return self._options.curve_drawer + def plotter(self) -> BasePlotter: + """A short-cut for curve plotter instance.""" + return self._options.plotter @classmethod def _default_options(cls) -> Options: """Return default analysis options. Analysis Options: - curve_drawer (BaseDrawer): A curve drawer instance to visualize + plotter (BasePlotter): A curve plotter instance to visualize the analysis result. plot_raw_data (bool): Set ``True`` to draw processed data points, dataset without formatting, on canvas. This is ``False`` by default. @@ -168,7 +168,7 @@ def _default_options(cls) -> Options: """ options = super()._default_options() - options.curve_drawer = MplDrawer() + options.plotter = CurvePlotter(MplDrawer()) options.plot_raw_data = False options.plot = True options.return_fit_parameters = True @@ -187,7 +187,7 @@ def _default_options(cls) -> Options: # Set automatic validator for particular option values options.set_validator(field="data_processor", validator_value=DataProcessor) - options.set_validator(field="curve_drawer", validator_value=BaseDrawer) + options.set_validator(field="plotter", validator_value=BasePlotter) return options diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index a1c1c5663f..f7007bb722 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -22,7 +22,7 @@ from uncertainties import unumpy as unp, UFloat from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options -from qiskit_experiments.visualization import MplDrawer, BaseDrawer +from qiskit_experiments.visualization import MplDrawer,CurvePlotter,BasePlotter from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties @@ -124,9 +124,9 @@ def models(self) -> Dict[str, List[lmfit.Model]]: return models @property - def drawer(self) -> BaseDrawer: - """A short-cut for curve drawer instance.""" - return self._options.curve_drawer + def plotter(self) -> BasePlotter: + """A short-cut for plotter instance.""" + return self._options.plotter def analyses( self, index: Optional[Union[str, int]] = None @@ -187,7 +187,7 @@ def _default_options(cls) -> Options: """Default analysis options. Analysis Options: - curve_drawer (BaseDrawer): A curve drawer instance to visualize + plotter (BasePlotter): A plotter instance to visualize the analysis result. plot (bool): Set ``True`` to create figure for fit result. This is ``True`` by default. @@ -200,7 +200,7 @@ def _default_options(cls) -> Options: """ options = super()._default_options() options.update_options( - curve_drawer=MplDrawer(), + plotter=CurvePlotter(MplDrawer()), plot=True, return_fit_parameters=True, return_data_points=False, @@ -208,7 +208,7 @@ def _default_options(cls) -> Options: ) # Set automatic validator for particular option values - options.set_validator(field="curve_drawer", validator_value=BaseDrawer) + options.set_validator(field="plotter", validator_value=BasePlotter) return options @@ -233,8 +233,8 @@ def _run_analysis( analysis_results = [] # Initialize canvas - if self.options.plot: - self.drawer.initialize_canvas() + # if self.options.plot: + # self.drawer.initialize_canvas() fit_dataset = {} for analysis in self._analyses: @@ -251,10 +251,10 @@ def _run_analysis( if self.options.plot and analysis.options.plot_raw_data: for model in analysis.models: sub_data = processed_data.get_subset_of(model._name) - self.drawer.draw_raw_data( - x_data=sub_data.x, - y_data=sub_data.y, - name=model._name + f"_{analysis.name}", + self.plotter.set_series_data( + model._name + f"_{analysis.name}", + x=sub_data.x, + y=sub_data.y, ) # Format data @@ -262,11 +262,11 @@ def _run_analysis( if self.options.plot: for model in analysis.models: sub_data = formatted_data.get_subset_of(model._name) - self.drawer.draw_formatted_data( - x_data=sub_data.x, - y_data=sub_data.y, - y_err_data=sub_data.y_err, - name=model._name + f"_{analysis.name}", + self.plotter.set_series_data( + model._name + f"_{analysis.name}", + x_formatted=sub_data.x, + y_formatted=sub_data.y, + y_formatted_err=sub_data.y_err, ) # Run fitting @@ -310,23 +310,16 @@ def _run_analysis( ) y_mean = unp.nominal_values(y_data_with_uncertainty) # Draw fit line - self.drawer.draw_fit_line( - x_data=interp_x, - y_data=y_mean, - name=model._name + f"_{analysis.name}", + self.plotter.set_series_data( + model._name + f"_{analysis.name}", + x_interp=interp_x, + y_mean=y_mean, ) if fit_data.covar is not None: # Draw confidence intervals with different n_sigma sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): - for n_sigma, alpha in self.drawer.options.plot_sigma: - self.drawer.draw_confidence_interval( - x_data=interp_x, - y_ub=y_mean + n_sigma * sigmas, - y_lb=y_mean - n_sigma * sigmas, - name=model._name + f"_{analysis.name}", - alpha=alpha, - ) + self.plotter.set_series_data(model._name,sigmas=sigmas) # Add raw data points if self.options.return_data_points: @@ -355,10 +348,10 @@ def _run_analysis( for group, fit_data in fit_dataset.items(): chisqs.append(r"reduced-$\chi^2$ = " + f"{fit_data.reduced_chisq: .4g} ({group})") report += "\n".join(chisqs) - self.drawer.draw_fit_report(description=report) + self.plotter.set_figure_data(fit_report=report) # Finalize canvas - self.drawer.format_canvas() - return analysis_results, [self.drawer.figure] + # self.drawer.format_canvas() + return analysis_results, [self.plotter.figure()] return analysis_results, [] diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index 949ccfdd15..deba467624 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -154,7 +154,7 @@ def __init__( "symbol": series_def.plot_symbol, "canvas": series_def.canvas, } - self.drawer.set_options(plot_options=plot_options) + self.plotter.set_options(plot_options=plot_options) self._models = models or [] self._name = name or self.__class__.__name__ @@ -467,9 +467,9 @@ def _run_analysis( self._initialize(experiment_data) analysis_results = [] - # Initialize canvas - if self.options.plot: - self.drawer.initialize_canvas() + # # Initialize canvas + # if self.options.plot: + # self.drawer.initialize_canvas() # Run data processing processed_data = self._run_data_processing( @@ -480,10 +480,10 @@ def _run_analysis( if self.options.plot and self.options.plot_raw_data: for model in self._models: sub_data = processed_data.get_subset_of(model._name) - self.drawer.draw_raw_data( - x_data=sub_data.x, - y_data=sub_data.y, - name=model._name, + self.plotter.set_series_data( + model._name, + x=sub_data.x, + y=sub_data.y, ) # for backward compatibility, will be removed in 0.4. self.__processed_data_set["raw_data"] = processed_data @@ -493,11 +493,11 @@ def _run_analysis( if self.options.plot: for model in self._models: sub_data = formatted_data.get_subset_of(model._name) - self.drawer.draw_formatted_data( - x_data=sub_data.x, - y_data=sub_data.y, - y_err_data=sub_data.y_err, - name=model._name, + self.plotter.set_series_data( + model._name, + x_formatted=sub_data.x, + y_formatted=sub_data.y, + y_formatted_err=sub_data.y_err, ) # for backward compatibility, will be removed in 0.4. self.__processed_data_set["fit_ready"] = formatted_data @@ -555,23 +555,16 @@ def _run_analysis( ) y_mean = unp.nominal_values(y_data_with_uncertainty) # Draw fit line - self.drawer.draw_fit_line( - x_data=interp_x, - y_data=y_mean, - name=model._name, + self.plotter.set_series_data( + model._name, + x_interp=interp_x, + y_mean=y_mean, ) if fit_data.covar is not None: # Draw confidence intervals with different n_sigma sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): - for n_sigma, alpha in self.drawer.options.plot_sigma: - self.drawer.draw_confidence_interval( - x_data=interp_x, - y_ub=y_mean + n_sigma * sigmas, - y_lb=y_mean - n_sigma * sigmas, - name=model._name, - alpha=alpha, - ) + self.plotter.set_series_data(model._name,sigmas=sigmas,) # Write fitting report report_description = "" @@ -579,7 +572,7 @@ def _run_analysis( if isinstance(res.value, (float, UFloat)): report_description += f"{analysis_result_to_repr(res)}\n" report_description += r"reduced-$\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" - self.drawer.draw_fit_report(description=report_description) + self.plotter.set_figure_data(fit_report=report_description) # Add raw data points if self.options.return_data_points: @@ -589,8 +582,8 @@ def _run_analysis( # Finalize plot if self.options.plot: - self.drawer.format_canvas() - return analysis_results, [self.drawer.figure] + # self.drawer.format_canvas() + return analysis_results, [self.plotter.figure()] return analysis_results, [] diff --git a/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py b/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py index 098f7e9a70..e62a718649 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py @@ -138,7 +138,7 @@ def _default_options(cls): input_key="counts", data_actions=[dp.Probability("1"), dp.BasisExpectationValue()], ) - default_options.curve_drawer.set_options( + default_options.plotter.drawer.set_options( xlabel="Flat top width", ylabel="Pauli expectation values", xval_unit="s", diff --git a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py index d3aa479698..e486a66421 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py @@ -105,7 +105,7 @@ def _default_options(cls): considered as good. Defaults to :math:`\pi/2`. """ default_options = super()._default_options() - default_options.curve_drawer.set_options( + default_options.plotter.drawer.set_options( xlabel="Number of gates (n)", ylabel="Population", ylim=(0, 1.0), diff --git a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py index 2c7f0cb442..07d9cc638d 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py @@ -76,7 +76,7 @@ def __init__( @classmethod def _default_options(cls) -> Options: options = super()._default_options() - options.curve_drawer.set_options( + options.plotter.drawer.set_options( xlabel="Frequency", ylabel="Signal (arb. units)", xval_unit="Hz", diff --git a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py index a07fc5a67e..7fa5fc581e 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py @@ -76,7 +76,7 @@ def __init__( @classmethod def _default_options(cls) -> Options: options = super()._default_options() - options.curve_drawer.set_options( + options.plotter.drawer.set_options( xlabel="Frequency", ylabel="Signal (arb. units)", xval_unit="Hz", diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index c6dd75c29e..70e82dc97c 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -60,7 +60,7 @@ def __init__(self): def _default_options(cls): """Return the default analysis options.""" default_options = super()._default_options() - default_options.curve_drawer.set_options( + default_options.plotter.drawer.set_options( subplots=(3, 1), xlabel="Flat top width", ylabel=[ diff --git a/qiskit_experiments/library/characterization/analysis/drag_analysis.py b/qiskit_experiments/library/characterization/analysis/drag_analysis.py index 35ad86537b..0c944311fd 100644 --- a/qiskit_experiments/library/characterization/analysis/drag_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/drag_analysis.py @@ -84,7 +84,7 @@ def _default_options(cls): descriptions of analysis options. """ default_options = super()._default_options() - default_options.curve_drawer.set_options( + default_options.plotter.drawer.set_options( xlabel="Beta", ylabel="Signal (arb. units)", ) diff --git a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py index 5765b53f7c..d083bbd0c7 100644 --- a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py @@ -88,7 +88,7 @@ def _default_options(cls): descriptions of analysis options. """ default_options = super()._default_options() - default_options.curve_drawer.set_options( + default_options.plotter.drawer.set_options( xlabel="Delay", ylabel="Signal (arb. units)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py index 0e306b988f..d6028a36d2 100644 --- a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py @@ -49,7 +49,7 @@ def _run_analysis( if self.options.plot_iq_data: axis = get_non_gui_ax() figure = axis.get_figure() - figure.set_size_inches(*self.drawer.options.figsize) + figure.set_size_inches(*self.plotter.drawer.options.figsize) iqs = [] @@ -68,12 +68,12 @@ def _run_analysis( iqs = np.vstack(iqs) axis.scatter(iqs[:, 0], iqs[:, 1], color="b") axis.set_xlabel( - "In phase [arb. units]", fontsize=self.drawer.options.axis_label_size + "In phase [arb. units]", fontsize=self.plotter.drawer.options.axis_label_size ) axis.set_ylabel( - "Quadrature [arb. units]", fontsize=self.drawer.options.axis_label_size + "Quadrature [arb. units]", fontsize=self.plotter.drawer.options.axis_label_size ) - axis.tick_params(labelsize=self.drawer.options.tick_label_size) + axis.tick_params(labelsize=self.plotter.drawer.options.tick_label_size) axis.grid(True) figures.append(figure) diff --git a/qiskit_experiments/library/characterization/analysis/t1_analysis.py b/qiskit_experiments/library/characterization/analysis/t1_analysis.py index f88020acb7..21c9262ea0 100644 --- a/qiskit_experiments/library/characterization/analysis/t1_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t1_analysis.py @@ -34,7 +34,7 @@ class T1Analysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.curve_drawer.set_options( + options.plotter.drawer.set_options( xlabel="Delay", ylabel="P(1)", xval_unit="s", @@ -85,7 +85,7 @@ class T1KerneledAnalysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.curve_drawer.set_options( + options.plotter.drawer.set_options( xlabel="Delay", ylabel="Normalized Projection on the Main Axis", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py b/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py index 09e1839cb4..3f0c7424c2 100644 --- a/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py @@ -34,7 +34,7 @@ class T2HahnAnalysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.curve_drawer.set_options( + options.plotter.drawer.set_options( xlabel="Delay", ylabel="P(0)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py b/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py index 3073e1793c..fb736bea2a 100644 --- a/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py @@ -29,7 +29,7 @@ class T2RamseyAnalysis(curve.DampedOscillationAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.curve_drawer.set_options( + options.plotter.drawer.set_options( xlabel="Delay", ylabel="P(1)", xval_unit="s", diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py index 0fb9c83764..7907a5afad 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py @@ -96,7 +96,7 @@ def _default_options(cls): 2Q RB is corrected to exclude the depolarization of underlying 1Q channels. """ default_options = super()._default_options() - default_options.curve_drawer.set_options( + default_options.plotter.drawer.set_options( xlabel="Clifford Length", ylabel="P(0)", ) diff --git a/test/curve_analysis/test_baseclass.py b/test/curve_analysis/test_baseclass.py index 55d75a46fd..f04d634deb 100644 --- a/test/curve_analysis/test_baseclass.py +++ b/test/curve_analysis/test_baseclass.py @@ -205,7 +205,7 @@ class InvalidClass: analysis.set_options(data_processor=InvalidClass()) with self.assertRaises(TypeError): - analysis.set_options(curve_drawer=InvalidClass()) + analysis.set_options(curve_plotter=InvalidClass()) def test_end_to_end_single_function(self): """Integration test for single function.""" From 14ab82f64404e1114687764fd82b391c0f6c90a3 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Wed, 14 Sep 2022 12:03:50 +0200 Subject: [PATCH 09/45] Add new PlotStyle class and move old PlotterStyle to fit_result_plotters.py PlotterStyle is only used in fit_result_plotters.py, which doesn't appear to be used anywhere in Qiskit Experiments. However, until the file is removed, PlotterStyle must be kept. --- qiskit_experiments/visualization/__init__.py | 5 +- .../visualization/fit_result_plotters.py | 40 +++++++- qiskit_experiments/visualization/style.py | 82 +++++++++++----- test/visualization/__init__.py | 12 +++ test/visualization/test_style.py | 96 +++++++++++++++++++ 5 files changed, 208 insertions(+), 27 deletions(-) create mode 100644 test/visualization/__init__.py create mode 100644 test/visualization/test_style.py diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 839ba5f242..60f50a6c2d 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -57,6 +57,7 @@ # :toctree: ../stubs/ # :template: autosummary/class.rst +# LegacyPlotterStyle # PlotterStyle # """ @@ -64,11 +65,11 @@ from enum import Enum from .drawers import BaseDrawer, MplDrawer -from .plotters import BasePlotter,CurvePlotter +from .plotters import BasePlotter, CurvePlotter +from .style import PlotStyle from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit -from .style import PlotterStyle # pylint: disable=invalid-name diff --git a/qiskit_experiments/visualization/fit_result_plotters.py b/qiskit_experiments/visualization/fit_result_plotters.py index 537c3ed759..81acef2474 100644 --- a/qiskit_experiments/visualization/fit_result_plotters.py +++ b/qiskit_experiments/visualization/fit_result_plotters.py @@ -33,7 +33,45 @@ from qiskit_experiments.framework import AnalysisResultData from qiskit_experiments.framework.matplotlib import get_non_gui_ax from .curves import plot_scatter, plot_errorbar, plot_curve_fit -from .style import PlotterStyle +import dataclasses +from typing import Tuple, List + + +@dataclasses.dataclass +class PlotterStyle: + """A stylesheet specific for :mod:`fit_result_plotters`. + + This style class is used by :mod:`fit_result_plotters`, but not by :class:`BasePlotter` or + :class:`BaseDrawer`. It is recommended that new code use the new :class:`BasePlotter` and + :class:`BaseDrawer` classes to plot figures and draw on a canvas. The + :mod:`qiskit_experiments.visualization` module contains a different + :class:`qiskit_experiments.visualization.PlottingStyle` class which is specific to + :class:`BasePlotter` and :class:`DrawerPlotter`. + """ + + # size of figure (width, height) + figsize: Tuple[int, int] = (8, 5) + + # legent location (vertical, horizontal) + legend_loc: str = "center right" + + # size of tick label + tick_label_size: int = 14 + + # size of axis label + axis_label_size: int = 16 + + # relative position of fit report + fit_report_rpos: Tuple[float, float] = (0.6, 0.95) + + # size of fit report text + fit_report_text_size: int = 14 + + # sigma values for confidence interval, which are the tuple of (sigma, alpha). + # the alpha indicates the transparency of the corresponding interval plot. + plot_sigma: List[Tuple[float, float]] = dataclasses.field( + default_factory=lambda: [(1.0, 0.7), (3.0, 0.3)] + ) class MplDrawSingleCanvas: diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index c63e0766bb..e27923d2e5 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -1,6 +1,6 @@ # This code is part of Qiskit. # -# (C) Copyright IBM 2021. +# (C) Copyright IBM 2022. # # 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 @@ -10,36 +10,70 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. """ -Configurable stylesheet. +Configurable stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. """ -import dataclasses -from typing import Tuple, List +from typing import Tuple +from qiskit_experiments.framework import Options +from copy import copy -@dataclasses.dataclass -class PlotterStyle: - """A stylesheet for curve analysis figure.""" +class PlotStyle(Options): + """A stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. - # size of figure (width, height) - figsize: Tuple[int, int] = (8, 5) + This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`, and must not be confused + with :class:`~qiskit_experiments.visualization.fit_result_plotters.PlotterStyle`. The default style for Qiskit Experiments is defined in :meth:`default_style`. + """ - # legent location (vertical, horizontal) - legend_loc: str = "center right" + @classmethod + def default_style(cls) -> "PlotStyle": + """The default style across Qiskit Experiments. - # size of tick label - tick_label_size: int = 14 + Returns: + PlotStyle: The default plot style used by Qiskit Experiments. + """ + new = cls() + # size of figure (width, height) + new.figsize: Tuple[int, int] = (8, 5) - # size of axis label - axis_label_size: int = 16 + # legent location (vertical, horizontal) + new.legend_loc: str = "center right" - # relative position of fit report - fit_report_rpos: Tuple[float, float] = (0.6, 0.95) + # size of tick label + new.tick_label_size: int = 14 - # size of fit report text - fit_report_text_size: int = 14 + # size of axis label + new.axis_label_size: int = 16 - # sigma values for confidence interval, which are the tuple of (sigma, alpha). - # the alpha indicates the transparency of the corresponding interval plot. - plot_sigma: List[Tuple[float, float]] = dataclasses.field( - default_factory=lambda: [(1.0, 0.7), (3.0, 0.3)] - ) + # relative position of fit report + new.fit_report_rpos: Tuple[float, float] = (0.6, 0.95) + + # size of fit report text + new.fit_report_text_size: int = 14 + + return new + + def update(self, other_style: "PlotStyle"): + """Updates the plot styles fields with those set in ``other_style``. + + Args: + other_style: The style with new field values. + """ + self.update_options(**other_style._fields) + + @classmethod + def merge(cls, style1: "PlotStyle", style2: "PlotStyle") -> "PlotStyle": + """Merges two PlotStyle instances. + + The styles are merged such that style fields in ``style2`` have priority. i.e., a field ``foo``, + defined in both input styles, will have the value :code-block:`style2.foo` in the output. + + Args: + style1: The first style. + style2: The second style. + + Returns: + PlotStyle: A plot style containing the combined fields of both input styles. + """ + new_style = copy(style1) + new_style.update(style2) + return new_style diff --git a/test/visualization/__init__.py b/test/visualization/__init__.py new file mode 100644 index 0000000000..441ae06e7f --- /dev/null +++ b/test/visualization/__init__.py @@ -0,0 +1,12 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +"""Test cases for visualization plotting.""" diff --git a/test/visualization/test_style.py b/test/visualization/test_style.py new file mode 100644 index 0000000000..2a6aaef6d3 --- /dev/null +++ b/test/visualization/test_style.py @@ -0,0 +1,96 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Test visualization plotter. +""" + +from typing import Tuple +from test.base import QiskitExperimentsTestCase +from qiskit_experiments.visualization import PlotStyle +from copy import copy + + +class TestPlotStyle(QiskitExperimentsTestCase): + """Test PlotStyle""" + + @classmethod + def _dummy_styles(cls) -> Tuple[PlotStyle, PlotStyle, PlotStyle, PlotStyle]: + """Returns dummy input styles for PlotStyle tests. + + Returns: + PlotStyle: First input style. + PlotStyle: Second input style. + PlotStyle: Expected style combining second into first. + PlotStyle: Expected style combining first into second. + """ + custom_1 = PlotStyle(overwrite_field=0, unchanged_field_A="Test", none_field=[0, 1, 2, 3]) + custom_2 = PlotStyle(overwrite_field=6, unchanged_field_B=0.5, none_field=None) + expected_12 = PlotStyle( + overwrite_field=6, + unchanged_field_A="Test", + unchanged_field_B=0.5, + none_field=None, + ) + expected_21 = PlotStyle( + overwrite_field=0, + unchanged_field_A="Test", + unchanged_field_B=0.5, + none_field=[0, 1, 2, 3], + ) + return custom_1, custom_2, expected_12, expected_21 + + def test_default_contains_necessary_fields(self): + """Test that expected fields are set in the default style.""" + default = PlotStyle.default_style() + expected_not_none_fields = [ + "figsize", + "legend_loc", + "tick_label_size", + "axis_label_size", + "fit_report_rpos", + "fit_report_text_size", + ] + for field in expected_not_none_fields: + self.assertIsNotNone(getattr(default, field)) + + def test_update(self): + """Test that styles can be updated.""" + custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() + + # copy(...) is needed as .update() modifies the style instance + actual_12 = copy(custom_1) + actual_12.update(custom_2) + actual_21 = copy(custom_2) + actual_21.update(custom_1) + + self.assertEqual(actual_12, expected_12) + self.assertEqual(actual_21, expected_21) + + def test_merge(self): + """Test that styles can be merged.""" + custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() + + self.assertEqual(PlotStyle.merge(custom_1, custom_2), expected_12) + self.assertEqual(PlotStyle.merge(custom_2, custom_1), expected_21) + + def test_field_access(self): + """Test that fields are accessed correctly""" + dummy_style = PlotStyle( + x="x", + # y isn't assigned and therefore doesn't exist in dummy_style + ) + + self.assertEqual(dummy_style.x, "x") + + # This should throw as we haven't assigned y + with self.assertRaises(AttributeError): + dummy_style.y From b45264f28dcea4e0befa96bd4263f38cd113d666 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 15 Sep 2022 16:15:01 +0200 Subject: [PATCH 10/45] Update plotter and drawers to split options into options and plot_options This commit also expands PlotStyle. --- qiskit_experiments/visualization/__init__.py | 86 +++--- .../visualization/drawers/base_drawer.py | 213 +++++++------ .../visualization/drawers/mpl_drawer.py | 98 +++--- .../visualization/fit_result_plotters.py | 25 +- .../visualization/plotters/base_plotter.py | 280 +++++++++++++++--- .../visualization/plotters/curve_plotter.py | 64 +++- qiskit_experiments/visualization/style.py | 25 +- test/visualization/mock_drawer.py | 99 +++++++ test/visualization/mock_plotter.py | 71 +++++ test/visualization/test_plotter.py | 87 ++++++ test/visualization/test_plotter_drawer.py | 79 +++++ test/visualization/test_plotter_mpldrawer.py | 39 +++ test/visualization/test_style.py | 11 +- 13 files changed, 932 insertions(+), 245 deletions(-) create mode 100644 test/visualization/mock_drawer.py create mode 100644 test/visualization/mock_plotter.py create mode 100644 test/visualization/test_plotter.py create mode 100644 test/visualization/test_plotter_drawer.py create mode 100644 test/visualization/test_plotter_mpldrawer.py diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 60f50a6c2d..ba9452d13b 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -9,64 +9,74 @@ # 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. -# r""" -# ========================================================= -# Visualization (:mod:`qiskit_experiments.visualization`) -# ========================================================= +r""" +========================================================= +Visualization (:mod:`qiskit_experiments.visualization`) +========================================================= -# .. currentmodule:: qiskit_experiments.visualization +.. currentmodule:: qiskit_experiments.visualization -# Visualization provides plotting functionality for experiment results and analysis classes. This includes -# drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. +Visualization provides plotting functionality for creating figures from experiment and analysis results. +This includes plotter and drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. -# Drawer Library -# ============== +Plotter Library +============== -# .. autosummary:: -# :toctree: ../stubs/ -# :template: autosummary/class.rst +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst -# BaseDrawer -# MplDrawer + BasePlotter + CurvePlotter -# Plotting Functions -# ================== +Drawer Library +============== -# .. autosummary:: -# :toctree: ../stubs/ +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst -# plot_curve_fit -# plot_errorbar -# plot_scatter + BaseDrawer + MplDrawer -# Curve Fitting Helpers -# ===================== +Plotting Style +============== -# .. autosummary:: -# :toctree: ../stubs/ -# :template: autosummary/class.rst +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst -# FitResultPlotters -# fit_result_plotters.MplDrawSingleCanvas -# fit_result_plotters.MplDrawMultiCanvasVstack + PlotStyle -# Plotting Style -# ============== +Plotting Functions +================== -# .. autosummary:: -# :toctree: ../stubs/ -# :template: autosummary/class.rst +.. autosummary:: + :toctree: ../stubs/ -# LegacyPlotterStyle -# PlotterStyle + plot_curve_fit + plot_errorbar + plot_scatter -# """ +Curve Fitting Helpers +===================== + +.. autosummary:: + :toctree: ../stubs/ + :template: autosummary/class.rst + + FitResultPlotters + fit_result_plotters.MplDrawSingleCanvas + fit_result_plotters.MplDrawMultiCanvasVstack + fit_result_plotters.PlottingStyle + +""" from enum import Enum +from .style import PlotStyle from .drawers import BaseDrawer, MplDrawer from .plotters import BasePlotter, CurvePlotter -from .style import PlotStyle from . import fit_result_plotters from .curves import plot_scatter, plot_errorbar, plot_curve_fit diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 12dfc95124..0631f86ca5 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -10,93 +10,130 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Curve drawer abstract class.""" +"""Drawer abstract class.""" from abc import ABC, abstractmethod from typing import Dict, Sequence, Optional from qiskit_experiments.framework import Options +from qiskit_experiments.visualization import PlotStyle class BaseDrawer(ABC): - """Abstract class for the serializable Qiskit Experiments curve drawer. + """Abstract class for the serializable Qiskit Experiments figure drawer. - A curve drawer may be implemented by different drawing backends such as matplotlib - or plotly. Sub-classes that wrap these backends by subclassing `BaseDrawer` must - implement the following abstract methods. + A drawer may be implemented by different drawer backends such as matplotlib or Plotly. Sub-classes + that wrap these backends by subclassing `BaseDrawer` must implement the following abstract methods. initialize_canvas - This method should implement a protocol to initialize a drawing canvas - with user input ``axis`` object. Note that curve analysis drawer - supports visualization of experiment results in multiple canvases - tiled into N (row) x M (column) inset grids, which is specified in the option ``subplots``. - By default, this is N=1, M=1 and thus no inset grid will be initialized. - The data points to draw might be provided with a canvas number defined in - :attr:`SeriesDef.canvas` which defaults to ``None``, i.e. no-inset grids. + This method should implement a protocol to initialize a drawer canvas with user input ``axis`` + object. Note that ``drawer`` supports visualization of experiment results in multiple canvases + tiled into N (row) x M (column) inset grids, which is specified in the option ``subplots``. By + default, this is N=1, M=1 and thus no inset grid will be initialized. The data points to draw + might be provided with a canvas number defined in :attr:`SeriesDef.canvas` which defaults to + ``None``, i.e. no-inset grids. - This method should first check the drawing options for the axis object - and initialize the axis only when it is not provided by the options. - Once axis is initialized, this is set to the instance member ``self._axis``. + This method should first check the drawer options (:attr:`options`) for the axis object and + initialize the axis only when it is not provided by the options. Once axis is initialized, this + is set to the instance member ``self._axis``. format_canvas - This method should implement a protocol to format the appearance of canvas. - Typically, it updates axis and tick labels. Note that the axis SI unit - may be specified in the drawing options. In this case, axis numbers should be - auto-scaled with the unit prefix. + This method should implement a protocol to format the appearance of canvas. Typically, it updates + axis and tick labels. Note that the axis SI unit may be specified in the drawer options. In this + case, axis numbers should be auto-scaled with the unit prefix. draw_raw_data - This method is called after data processing is completed. - This method draws raw experiment data points on the canvas. + This method draws raw experiment data points on the canvas, like a scatter-plot. draw_formatted_data - This method is called after data formatting is completed. - The formatted data might be averaged over the same x values, - or smoothed by a filtering algorithm, depending on how analysis class is implemented. - This method is called with error bars of y values and the name of the curve. + This method plots data with error-bars for the y-values. The formatted data might be averaged + over the same x values, or smoothed by a filtering algorithm, depending on how analysis class is + implemented. This method is called with error bars of y values and the name of the series. - draw_fit_line + draw_line - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the interpolated x and y values. + This method plots a line from provided X and Y values. This method is typically called with + interpolated x and y values from a curve-fit. draw_confidence_interval - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the interpolated x and a pair of y values - that represent the upper and lower bound within certain confidence interval. - This might be called multiple times with different interval sizes. + This method plots a shaped region bounded by upper and lower Y-values. This method is typically + called with interpolated x and a pair of y values that represent the upper and lower bound within + certain confidence interval. This might be called multiple times with different interval sizes. + It is normally good to set some transparency for a confidence interval so the figure has enough + contrast between points, lines, and the confidence-interval shape. - draw_fit_report + draw_report - This method is called after fitting is completed and when there is valid fit outcome. - This method is called with the list of analysis results and the reduced chi-squared values. - The fit report should be generated to show this information on the canvas. + This method draws a report on the canvas, which is a rectangular region containing some text. + This method is typically called with a list of analysis results and reduced chi-squared values + from a curve-fit. """ def __init__(self): + """Create a BaseDrawer instance.""" + # Normal options. Which includes the drawer axis, subplots, and default style. self._options = self._default_options() + # A set of changed options for serialization. self._set_options = set() + + # Plot options which are typically updated by a plotter instance. Plot-options include the axis + # labels, figure title, and a custom style instance. + self._plot_options = self._default_plot_options() + # A set of changed plot-options for serialization. + self._set_plot_options = set() + + # The initialized axis/axes, set by `initialize_canvas`. self._axis = None - self._curves = list() @property def options(self) -> Options: - """Return the drawing options.""" + """Return the drawer options.""" return self._options + @property + def plot_options(self) -> Options: + """Return the plot options. + + These are typically updated by a plotter instance, and thus may change. It is recommended to set + plot options in a parent :class:`BasePlotter` instance that contains the :class:`BaseDrawer` + instance. + """ + return self._plot_options + @classmethod def _default_options(cls) -> Options: - """Return default draw options. + """Return default drawer options. - Draw Options: - axis (Any): Arbitrary object that can be used as a drawing canvas. + Drawer Options: + axis (Any): Arbitrary object that can be used as a canvas. subplots (Tuple[int, int]): Number of rows and columns when the experimental result is drawn in the multiple windows. + default_style (PlotStyle): The default style for drawer. + This must contain all required style parameters for :class:`drawer`, as is defined in + :meth:`PlotStyle.default_style()`. Subclasses can add extra required style parameters by + overriding :meth:`_default_style`. + """ + return Options( + axis=None, + subplots=(1, 1), + default_style=cls._default_style(), + ) + + @classmethod + def _default_style(cls) -> PlotStyle: + return PlotStyle.default_style() + + @classmethod + def _default_plot_options(cls) -> Options: + """Return default plot options. + + Plot Options: xlabel (Union[str, List[str]]): X-axis label string of the output figure. If there are multiple columns in the canvas, this could be a list of labels. ylabel (Union[str, List[str]]): Y-axis label string of the output figure. @@ -115,65 +152,63 @@ def _default_options(cls) -> Options: and no scaling is applied. If nothing is provided, the axis numbers will be displayed in the scientific notation. yval_unit (str): Unit of y values. See ``xval_unit`` for details. - figsize (Tuple[int, int]): A tuple of two numbers representing the size of - the output figure (width, height). Note that this is applicable - only when ``axis`` object is not provided. If any canvas object is provided, - the figure size associated with the axis is preferentially applied. - legend_loc (str): Vertical and horizontal location of the curve legend window in - a single string separated by a space. This defaults to ``center right``. - Vertical position can be ``upper``, ``center``, ``lower``. - Horizontal position can be ``right``, ``center``, ``left``. - tick_label_size (int): Size of text representing the axis tick numbers. - axis_label_size (int): Size of text representing the axis label. - fit_report_rpos (Tuple[int, int]): A tuple of numbers showing the location of - the fit report window. These numbers are horizontal and vertical position - of the top left corner of the window in the relative coordinate - on the output figure, i.e. ``[0, 1]``. - The fit report window shows the selected fit parameters and the reduced - chi-squared value. - fit_report_text_size (int): Size of text in the fit report window. - plot_sigma (List[Tuple[float, float]]): A list of two number tuples - showing the configuration to write confidence intervals for the fit curve. - The first argument is the relative sigma (n_sigma), and the second argument is - the transparency of the interval plot in ``[0, 1]``. - Multiple n_sigma intervals can be drawn for the single curve. - plot_options (Dict[str, Dict[str, Any]]): A dictionary of plot options for each curve. - This is keyed on the model name for each curve. Sub-dictionary is expected to have - following three configurations, "canvas", "color", and "symbol"; "canvas" is the - integer index of axis (when multi-canvas plot is set), "color" is the - color of the curve, and "symbol" is the marker style of the curve for scatter plots. figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. + series_params (Dict[str, Dict[str, Any]]): A dictionary of plot parameters for each series. + This is keyed on the name for each series. Sub-dictionary is expected to have following + three configurations, "canvas", "color", and "symbol"; "canvas" is the integer index of + axis (when multi-canvas plot is set), "color" is the color of the series, and "symbol" is + the marker style of the series. Defaults to an empty dictionary. + custom_style (PlotStyle): The style definition to use when drawing. This overwrites style + parameters in ``default_style`` in :attr:`options`. Defaults to an empty PlotStyle + instance (i.e., :code-block:`PlotStyle()`). """ return Options( - axis=None, - subplots=(1, 1), xlabel=None, ylabel=None, xlim=None, ylim=None, xval_unit=None, yval_unit=None, - figsize=(8, 5), - legend_loc="center right", - tick_label_size=14, - axis_label_size=16, - fit_report_rpos=(0.6, 0.95), - fit_report_text_size=14, - plot_options={}, figure_title=None, + series_params={}, + custom_style=PlotStyle(), ) def set_options(self, **fields): - """Set the drawing options. + """Set the drawer options. Args: fields: The fields to update the options """ self._options.update_options(**fields) self._set_options = self._set_options.union(fields) + def set_plot_options(self, **fields): + """Set the plot options. + Args: + fields: The fields to update the plot options + """ + self._plot_options.update_options(**fields) + self._set_plot_options = self._set_plot_options.union(fields) + + @property + def style(self) -> PlotStyle: + """The combined plot style for this drawer. + + The returned style instance is a combination of :attr:`options.default_style` and + :attr:`plot_options.custom_style`. Style parameters set in ``custom_style`` override those set in + ``default_style``. If ``custom_style`` is not an instance of :class:`PlotStyle`, the returned + style is equivalent to ``default_style``. + + Returns: + PlotStyle: The plot style for this drawer. + """ + if isinstance(self.plot_options.custom_style, PlotStyle): + return PlotStyle.merge(self.options.default_style, self.plot_options.custom_style) + return self.options.default_style + @abstractmethod def initialize_canvas(self): - """Initialize the drawing canvas.""" + """Initialize the drawer canvas.""" @abstractmethod def format_canvas(self): @@ -192,7 +227,7 @@ def draw_raw_data( Args: x_data: X values. y_data: Y values. - name: Name of this curve. + name: Name of this series. options: Valid options for the drawer backend API. """ @@ -211,12 +246,12 @@ def draw_formatted_data( x_data: X values. y_data: Y values. y_err_data: Standard deviation of Y values. - name: Name of this curve. + name: Name of this series. options: Valid options for the drawer backend API. """ @abstractmethod - def draw_fit_line( + def draw_line( self, x_data: Sequence[float], y_data: Sequence[float], @@ -228,7 +263,7 @@ def draw_fit_line( Args: x_data: X values. y_data: Fit Y values. - name: Name of this curve. + name: Name of this series. options: Valid options for the drawer backend API. """ @@ -241,26 +276,26 @@ def draw_confidence_interval( name: Optional[str] = None, **options, ): - """Draw cofidence interval. + """Draw confidence interval. Args: x_data: X values. y_ub: The upper boundary of Y values. y_lb: The lower boundary of Y values. - name: Name of this curve. + name: Name of this series. options: Valid options for the drawer backend API. """ @abstractmethod - def draw_fit_report( + def draw_report( self, description: str, **options, ): - """Draw text box that shows fit reports. + """Draw text box that shows reports, such as fit results. Args: - description: A string to describe the fiting outcome. + description: A string to be drawn inside a report box. options: Valid options for the drawer backend API. """ @@ -270,7 +305,7 @@ def figure(self): """Return figure object handler to be saved in the database.""" def config(self) -> Dict: - """Return the config dictionary for this drawing.""" + """Return the config dictionary for this drawer.""" options = dict((key, getattr(self._options, key)) for key in self._set_options) return {"cls": type(self), "options": options} diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 5bd8a33091..fd9e6de1cd 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -28,7 +28,7 @@ class MplDrawer(BaseDrawer): - """Curve drawer for MatplotLib backend.""" + """Drawer for MatplotLib backend.""" DefaultMarkers = MarkerStyle().filled_markers DefaultColors = tab10.colors @@ -47,12 +47,18 @@ def __init__(self, factor: float): def __call__(self, x, pos=None): return self.fix_minus("{:.3g}".format(x * self.factor)) + def __init__(self): + super().__init__() + # Used to track which series have already been plotted. Needed for _get_default_marker and + # _get_default_color. + self._series = list() + def initialize_canvas(self): # Create axis if empty if not self.options.axis: axis = get_non_gui_ax() figure = axis.get_figure() - figure.set_size_inches(*self.options.figsize) + figure.set_size_inches(*self.style.figsize) else: axis = self.options.axis @@ -81,34 +87,34 @@ def initialize_canvas(self): sub_ax.set_yticklabels([]) else: # this axis locates at left, write y-label - if self.options.ylabel: - label = self.options.ylabel + if self.plot_options.ylabel: + label = self.plot_options.ylabel if isinstance(label, list): # Y label can be given as a list for each sub axis label = label[i] - sub_ax.set_ylabel(label, fontsize=self.options.axis_label_size) + sub_ax.set_ylabel(label, fontsize=self.style.axis_label_size) if i != n_rows - 1: # remove x axis except for most-bottom plot sub_ax.set_xticklabels([]) else: # this axis locates at bottom, write x-label - if self.options.xlabel: - label = self.options.xlabel + if self.plot_options.xlabel: + label = self.plot_options.xlabel if isinstance(label, list): # X label can be given as a list for each sub axis label = label[j] - sub_ax.set_xlabel(label, fontsize=self.options.axis_label_size) + sub_ax.set_xlabel(label, fontsize=self.style.axis_label_size) if j == 0 or i == n_rows - 1: # Set label size for outer axes where labels are drawn - sub_ax.tick_params(labelsize=self.options.tick_label_size) + sub_ax.tick_params(labelsize=self.style.tick_label_size) sub_ax.grid() # Remove original axis frames axis.axis("off") else: - axis.set_xlabel(self.options.xlabel, fontsize=self.options.axis_label_size) - axis.set_ylabel(self.options.ylabel, fontsize=self.options.axis_label_size) - axis.tick_params(labelsize=self.options.tick_label_size) + axis.set_xlabel(self.plot_options.xlabel, fontsize=self.style.axis_label_size) + axis.set_ylabel(self.plot_options.ylabel, fontsize=self.style.axis_label_size) + axis.tick_params(labelsize=self.style.tick_label_size) axis.grid() self._axis = axis @@ -124,17 +130,17 @@ def format_canvas(self): for sub_ax in all_axes: _, labels = sub_ax.get_legend_handles_labels() if len(labels) > 1: - sub_ax.legend() + sub_ax.legend(loc=self.style.legend_loc) # Format x and y axis for ax_type in ("x", "y"): # Get axis formatter from drawing options if ax_type == "x": - lim = self.options.xlim - unit = self.options.xval_unit + lim = self.plot_options.xlim + unit = self.plot_options.xval_unit else: - lim = self.options.ylim - unit = self.options.yval_unit + lim = self.plot_options.ylim + unit = self.plot_options.yval_unit # Compute data range from auto scale if not lim: @@ -198,10 +204,10 @@ def format_canvas(self): all_axes[0].set_ylim(lim) # Add title - if self.options.figure_title is not None: + if self.plot_options.figure_title is not None: self._axis.set_title( - label=self.options.figure_title, - fontsize=self.options.axis_label_size, + label=self.plot_options.figure_title, + fontsize=self.style.axis_label_size, ) def _get_axis(self, index: Optional[int] = None) -> Axes: @@ -228,33 +234,33 @@ def _get_axis(self, index: Optional[int] = None) -> Axes: return self._axis def _get_default_color(self, name: str) -> Tuple[float, ...]: - """A helper method to get default color for the curve. + """A helper method to get default color for the series. Args: - name: Name of the curve. + name: Name of the series. Returns: Default color available in matplotlib. """ - if name not in self._curves: - self._curves.append(name) + if name not in self._series: + self._series.append(name) - ind = self._curves.index(name) % len(self.DefaultColors) + ind = self._series.index(name) % len(self.DefaultColors) return self.DefaultColors[ind] def _get_default_marker(self, name: str) -> str: """A helper method to get default marker for the scatter plot. Args: - name: Name of the curve. + name: Name of the series. Returns: Default marker available in matplotlib. """ - if name not in self._curves: - self._curves.append(name) + if name not in self._series: + self._series.append(name) - ind = self._curves.index(name) % len(self.DefaultMarkers) + ind = self._series.index(name) % len(self.DefaultMarkers) return self.DefaultMarkers[ind] def draw_raw_data( @@ -264,9 +270,9 @@ def draw_raw_data( name: Optional[str] = None, **options, ): - curve_opts = self.options.plot_options.get(name, {}) - marker = curve_opts.get("symbol", self._get_default_marker(name)) - axis = curve_opts.get("canvas", None) + series_params = self.plot_options.series_params.get(name, {}) + marker = series_params.get("symbol", self._get_default_marker(name)) + axis = series_params.get("canvas", None) draw_options = { "color": "grey", @@ -285,10 +291,10 @@ def draw_formatted_data( name: Optional[str] = None, **options, ): - curve_opts = self.options.plot_options.get(name, {}) - axis = curve_opts.get("canvas", None) - color = curve_opts.get("color", self._get_default_color(name)) - marker = curve_opts.get("symbol", self._get_default_marker(name)) + series_params = self.plot_options.series_params.get(name, {}) + axis = series_params.get("canvas", None) + color = series_params.get("color", self._get_default_color(name)) + marker = series_params.get("symbol", self._get_default_marker(name)) draw_ops = { "color": color, @@ -306,16 +312,16 @@ def draw_formatted_data( y_err_data = None self._get_axis(axis).errorbar(x_data, y_data, yerr=y_err_data, **draw_ops) - def draw_fit_line( + def draw_line( self, x_data: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, **options, ): - curve_opts = self.options.plot_options.get(name, {}) - axis = curve_opts.get("canvas", None) - color = curve_opts.get("color", self._get_default_color(name)) + series_params = self.plot_options.series_params.get(name, {}) + axis = series_params.get("canvas", None) + color = series_params.get("color", self._get_default_color(name)) draw_ops = { "color": color, @@ -334,9 +340,9 @@ def draw_confidence_interval( name: Optional[str] = None, **options, ): - curve_opts = self.options.plot_options.get(name, {}) - axis = curve_opts.get("canvas", None) - color = curve_opts.get("color", self._get_default_color(name)) + series_params = self.plot_options.series_params.get(name, {}) + axis = series_params.get("canvas", None) + color = series_params.get("color", self._get_default_color(name)) draw_ops = { "zorder": 3, @@ -346,7 +352,7 @@ def draw_confidence_interval( draw_ops.update(**options) self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops) - def draw_fit_report( + def draw_report( self, description: str, **options, @@ -361,11 +367,11 @@ def draw_fit_report( bbox_props.update(**options) report_handler = self._axis.text( - *self.options.fit_report_rpos, + *self.style.report_rpos, s=description, ha="center", va="top", - size=self.options.fit_report_text_size, + size=self.style.report_text_size, transform=self._axis.transAxes, zorder=6, ) diff --git a/qiskit_experiments/visualization/fit_result_plotters.py b/qiskit_experiments/visualization/fit_result_plotters.py index 81acef2474..e43811597b 100644 --- a/qiskit_experiments/visualization/fit_result_plotters.py +++ b/qiskit_experiments/visualization/fit_result_plotters.py @@ -21,20 +21,19 @@ This is just like a function, but allows serialization via Enum. """ +import dataclasses from collections import defaultdict -from typing import List, Dict, Optional +from typing import Dict, List, Optional, Tuple -import uncertainties import numpy as np +import uncertainties from matplotlib.ticker import FuncFormatter from qiskit.utils import detach_prefix - -from qiskit_experiments.curve_analysis.curve_data import SeriesDef, FitData, CurveData +from qiskit_experiments.curve_analysis.curve_data import CurveData, FitData, SeriesDef from qiskit_experiments.framework import AnalysisResultData from qiskit_experiments.framework.matplotlib import get_non_gui_ax -from .curves import plot_scatter, plot_errorbar, plot_curve_fit -import dataclasses -from typing import Tuple, List + +from .curves import plot_curve_fit, plot_errorbar, plot_scatter @dataclasses.dataclass @@ -61,11 +60,11 @@ class PlotterStyle: # size of axis label axis_label_size: int = 16 - # relative position of fit report - fit_report_rpos: Tuple[float, float] = (0.6, 0.95) + # relative position of report + report_rpos: Tuple[float, float] = (0.6, 0.95) - # size of fit report text - fit_report_text_size: int = 14 + # size of report text + report_text_size: int = 14 # sigma values for confidence interval, which are the tuple of (sigma, alpha). # the alpha indicates the transparency of the corresponding interval plot. @@ -157,7 +156,7 @@ def draw( report_str += r"Fit $\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" report_handler = axis.text( - *style.fit_report_rpos, + *style.report_rpos, report_str, ha="center", va="top", @@ -309,7 +308,7 @@ def draw( report_str += r"Fit $\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" report_handler = axis.text( - *style.fit_report_rpos, + *style.report_rpos, report_str, ha="center", va="top", diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 0c2a95f488..b64592bb69 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -11,29 +11,37 @@ # that they have been altered from the originals. """Base plotter abstract class""" +import warnings from abc import ABC, abstractmethod -from typing import Any, Iterable, Union, Optional, List, Tuple, Dict +from typing import Any, Dict, List, Optional, Tuple, Union + from qiskit_experiments.framework import Options from qiskit_experiments.visualization.drawers import BaseDrawer +from qiskit_experiments.visualization.style import PlotStyle class BasePlotter(ABC): """An abstract class for the serializable figure plotters. - A plotter takes data from an experiment analysis class and plots a given figure using a drawing - backend. Sub-classes define the kind of figure created. + A plotter takes data from an experiment analysis class or experiment and plots a given figure using a + drawing backend. Sub-classes define the kind of figure created and the expected data. - Data is grouped into series, identified by a series name (str). There can be multiple different sets - of data associated with a given series name, which are identified by a data key (str). Experimental - and analysis results can be passed to the plotter so appropriate graphics can be drawn on the figure - canvas. Adding data is done through :meth:`set_series_data` and :meth:`set_figure_data`, with - querying done through the :meth:`data_for` and :meth:`data_exists_for` methods. + Data is split into series and figure data. Series data is grouped by series name (str). For + :class:`CurveAnalysis`, this is the model name for a curve fit. For series data associated with a + single series name and figure data, data-values are identified by a data-key (str). Different data + per series and figure must have a different data-key to avoid overwriting values. Experiment and + analysis results can be passed to the plotter so appropriate graphics can be drawn on the figure + canvas. Series data is added to the plotter using :meth:`set_series_data` whereas figure data is + added using :meth:`set_figure_data`. Series and figure data are retrieved using :meth:`data_for` and + :attr:`figure_data` respectively. - There are two types of data associated with a plotter: series and figure data. The former is a - dataset of values to be plotted on a canvas, such that the data can be grouped into subsets + Series data contains values to be plotted on a canvas, such that the data can be grouped into subsets identified by their series name. Series names can be thought of as legend labels for the plotted - data. Figure data is not associated with a series and is instead only associated with the figure. - Examples include analysis reports or other text that is drawn onto the figure canvas. + data, and as curve names for a curve-fit. Figure data is not associated with a series or curve and is + instead only associated with the figure. Examples include analysis reports or other text that is + drawn onto the figure canvas. + + # TODO: Add example usage and description of options and plot-options. """ def __init__(self, drawer: BaseDrawer): @@ -42,10 +50,22 @@ def __init__(self, drawer: BaseDrawer): Args: drawer: The drawer to use when creating the figure. """ + # Data to be plotted, such as scatter points, interpolated fits, and confidence intervals self._series_data: Dict[str, Dict[str, Any]] = {} + # Data for figure-wide drawing, unrelated to series data, such as text or fit reports. self._figure_data: Dict[str, Any] = {} + + # Options for the plotter self._options = self._default_options() + # Plotter options that have changed, for serialization. self._set_options = set() + + # Plot options that are updated in the drawer when `plotter.figure()` is called + self._plot_options = self._default_plot_options() + # Plot options that have changed, for serialization. + self._set_plot_options = set() + + # The drawer backend to use for plotting. self._drawer = drawer @property @@ -60,15 +80,30 @@ def drawer(self, new_drawer: BaseDrawer): @property def figure_data(self) -> Dict[str, Any]: + """Data for the figure being plotted. + + Figure data includes text, fit reports, or other data that is associated with the figure as a + whole and not an individual series. + """ return self._figure_data @property def series_data(self) -> Dict[str, Dict[str, Any]]: + """Data for series being plotted. + + Series data includes data such as scatter points, interpolated fit values, and + standard-deviations. Series data is grouped by series-name and then by a data-key, both strings. + Though series data can be accessed through :meth:`series_data`, it is recommended to use + :meth:`data_for` and :meth:`data_exists_for`. + + Returns: + dict: A dictionary containing series data. + """ return self._series_data @property def series(self) -> List[str]: - """The series names for this plotter.""" + """Series names that have been added to this plotter.""" return list(self._series_data.keys()) def data_keys_for(self, series_name: str) -> List[str]: @@ -88,7 +123,8 @@ def data_keys_for(self, series_name: str) -> List[str]: def data_for(self, series_name: str, data_keys: Union[str, List[str]]) -> Tuple[Optional[Any]]: """Returns data associated with the given series. - The returned tuple contains the data, associated with ``data_keys``, in the same orders as they are provided. For example, + The returned tuple contains the data, associated with ``data_keys``, in the same orders as they + are provided. For example, .. code-example::python plotter.set_series_data("seriesA", x=data.x, y=data.y, yerr=data.yerr) @@ -97,18 +133,20 @@ def data_for(self, series_name: str, data_keys: Union[str, List[str]]) -> Tuple[ x, y, yerr = plotter.series_data_for("seriesA", ["x", "y", "yerr"]) x, y, yerr = data.x, data.y, data.yerr - :meth:`series_data_for` is intended to be used by sub-classes of :class:`BasePlotter` when + :meth:`data_for` is intended to be used by sub-classes of :class:`BasePlotter` when plotting in :meth:`_plot_figure`. Args: series_name: The series name for the given series. - data_keys: List of data-keys for the data to be returned. + data_keys: List of data-keys for the data to be returned. If a single data-key is given as a + string, it is wrapped in a list. Returns: - tuple: A tuple of data associated with the given series, identified by ``data_keys``. + tuple: A tuple of data associated with the given series, identified by ``data_keys``. If no + data has been set for a data-key, None is returned for the associated tuple entry. """ - # We may be given a single data-key, but we need an iterable for the rest of the function. + # We may be given a single data-key, but we need a list for the rest of the function. if not isinstance(data_keys, list): data_keys = [data_keys] @@ -116,18 +154,30 @@ def data_for(self, series_name: str, data_keys: Union[str, List[str]]) -> Tuple[ if series_name not in self._series_data: return (None,) * len(data_keys) - return (self._series_data[series_name].get(key, None) for key in data_keys) + return tuple(self._series_data[series_name].get(key, None) for key in data_keys) def set_series_data(self, series_name: str, **data_kwargs): """Sets data for the given series. Note that if data has already been assigned for the given series and data-key, it will be - overridden by the new values. + overwritten with the new values. ``set_series_data`` will warn if the data-key is unexpected; + i.e., not within those returned by :meth:`expected_series_data_keys`. Args: series_name: The name of the given series. data_kwargs: The data to be added, where the keyword is the data-key. """ + # Warn if the data-keys are not expected. + unknown_data_keys = [ + data_key for data_key in data_kwargs if data_key not in self.expected_series_data_keys() + ] + for unknown_data_key in unknown_data_keys: + warnings.warn( + f"{self.__class__.__name__} encountered an unknown data-key {unknown_data_key}. It may " + "not be used by the plotter class." + ) + + # Set data if series_name not in self._series_data: self._series_data[series_name] = {} self._series_data[series_name].update(**data_kwargs) @@ -149,9 +199,26 @@ def set_figure_data(self, **data_kwargs): Figure data differs from series data in that it is not associate with a series name. Fit reports are examples of figure data as they are drawn on figures to report on analysis results and the - "goodness" of a curve-fit, not on the specific of a given line, point, or shape drawn on the - figure canvas. + "goodness" of a curve-fit, not a specific line, point, or shape drawn on the figure canvas. + + Note that if data has already been assigned for the given data-key, it will be overwritten with + the new values. ``set_figure_data`` will warn if the data-key is unexpected; i.e., not within + those returned by :meth:`expected_figure_data_keys`. + """ + + # Warn if any data-keys are not expected. + unknown_data_keys = [ + data_key + for data_key in data_kwargs + if data_key not in self.expected_figure_data_keys()() + ] + for unknown_data_key in unknown_data_keys: + warnings.warn( + f"{self.__class__.__name__} encountered an unknown data-key {unknown_data_key}. It may " + "not be used by the plotter class." + ) + self._figure_data.update(**data_kwargs) def clear_figure_data(self): @@ -176,65 +243,195 @@ def data_exists_for(self, series_name: str, data_keys: Union[str, List[str]]) -> if series_name not in self._series_data: return False - return all([key in self._series_data[series_name] for key in data_keys]) + return all(key in self._series_data[series_name] for key in data_keys) @abstractmethod def _plot_figure(self): """Generates a figure using :attr:`drawer` and :meth:`data`. Sub-classes must override this function to plot data using the drawer. This function is called by - :meth:`figure`. + :meth:`figure` when :attr:`drawer` can be used to draw on the canvas. """ def figure(self) -> Any: - """Generates and returns a figure for the already provided data. + """Generates and returns a figure for the already provided series and figure data. - :meth:`figure` calls :meth:`_plot_figure`, which is overridden by sub-classes. Before and after calling :meth:`_plot_figure`, :func:`initialize_canvas` and :func:`format_canvas` are called on the drawer respectively. + :meth:`figure` calls :meth:`_plot_figure`, which is overridden by sub-classes. Before and after + calling :meth:`_plot_figure`; :func:`_initialize_drawer`, :func:`initialize_canvas` and + :func:`format_canvas` are called on the drawer respectively. Returns: - Any: A figure generated by :attr:`drawer`. + Any: A figure generated by :attr:`drawer`, of the same type as ``drawer.figure``. """ + # Initialize drawer, to copy axis, subplots, and plot-options across. + self._initialize_drawer() + + # Initialize canvas, which creates subplots, assigns axis labels, etc. self.drawer.initialize_canvas() + + # Plot figure for given subclass. This is the core of BasePlotter subclasses. self._plot_figure() + + # Final formatting of canvas, which sets axis limits etc. self.drawer.format_canvas() + + # Return whatever figure is created by the drawer. return self.drawer.figure + @classmethod + @abstractmethod + def expected_series_data_keys(cls) -> List[str]: + """Returns the expected series data-keys supported by this plotter.""" + + @classmethod + @abstractmethod + def expected_figure_data_keys(cls) -> List[str]: + """Returns the expected figures data-keys supported by this plotter.""" + @property def options(self) -> Options: + """Options for the plotter. + + Options for a plotter modify how the class generates a figure. This includes an optional axis + object, being the drawer canvas. Make sure verify whether the option you want to set is in + :attr:`options` or :attr:`plot_options`. + """ return self._options - @classmethod - @abstractmethod - def _default_series_data_keys(cls) -> List[str]: - """Returns the default series data-keys supported by this plotter. + @property + def plot_options(self) -> Options: + """Plot options for the plotter and its drawer. - Returns: - list: List of data-keys. + Plot options differ from normal options (:attr:`options`) in that the plotter passes plot options + on to the drawer when creating a figure (when :meth:`figure` is called). This way :attr:`drawer` + can draw an appropriate figure. An example of a plot option is the x-axis label. """ - # TODO: This function is meant to be similar to _default_options, so that data-keys are defined somewhere. Not sure if this is the best way of doing it. + return self._plot_options @classmethod def _default_options(cls) -> Options: - """Return default plotting options.""" - return Options() + """Return default plotter options. + + Options: + axis (Any): Arbitrary object that can be used as a drawing canvas. + subplots (Tuple[int, int]): Number of rows and columns when the experimental + result is drawn in the multiple windows. + style (PlotStyle): The style definition to use when plotting. + This overwrites plotting options set in :attr:`drawer`. The default is an empty style + object, and such the default drawer plotting style will be used. + """ + return Options( + axis=None, + subplots=(1, 1), + style=PlotStyle(), + ) + + @classmethod + def _default_plot_options(cls) -> Options: + """Return default plot options. + + Plot Options: + xlabel (Union[str, List[str]]): X-axis label string of the output figure. + If there are multiple columns in the canvas, this could be a list of labels. + ylabel (Union[str, List[str]]): Y-axis label string of the output figure. + If there are multiple rows in the canvas, this could be a list of labels. + xlim (Tuple[float, float]): Min and max value of the horizontal axis. + If not provided, it is automatically scaled based on the input data points. + ylim (Tuple[float, float]): Min and max value of the vertical axis. + If not provided, it is automatically scaled based on the input data points. + xval_unit (str): SI unit of x values. No prefix is needed here. + For example, when the x values represent time, this option will be just "s" rather than + "ms". In the output figure, the prefix is automatically selected based on the maximum + value in this axis. If your x values are in [1e-3, 1e-4], they are displayed as [1 ms, 10 + ms]. This option is likely provided by the analysis class rather than end-users. However, + users can still override if they need different unit notation. By default, this option is + set to ``None``, and no scaling is applied. If nothing is provided, the axis numbers will + be displayed in the scientific notation. + yval_unit (str): Unit of y values. See ``xval_unit`` for details. + figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. + series_params (Dict[str, Dict[str, Any]]): A dictionary of plot parameters for each series. + This is keyed on the name for each series. Sub-dictionary is expected to have following + three configurations, "canvas", "color", and "symbol"; "canvas" is the integer index of + axis (when multi-canvas plot is set), "color" is the color of the curve, and "symbol" is + the marker style of the curve for scatter plots. + """ + return Options( + xlabel=None, + ylabel=None, + xlim=None, + ylim=None, + xval_unit=None, + yval_unit=None, + figure_title=None, + series_params={}, + ) def set_options(self, **fields): """Set the plotter options. Args: - fields: The fields to update the options. + fields: The fields to update in options. """ self._options.update_options(**fields) self._set_options = self._set_options.union(fields) + def set_plot_options(self, **fields): + """Set the plot options. + + Args: + fields: The fields to update in plot options. + """ + self._plot_options.update_options(**fields) + self._set_plot_options = self._set_plot_options.union(fields) + + def _initialize_drawer(self): + """Configures :attr:`drawer` before plotting. + + The following actions are taken: + 1. ``axis``, ``subplots``, and ``style`` are passed to :attr:`drawer`. + 2. ``plot_options`` in :attr:`drawer` are updated based on values set in plotter + :attr:`plot_options` + + These steps are different as any plot-option can be passed to :attr:`drawer` if the drawer has a + plot-option with the same name. However, ``axis``, ``subplots``, and ``style`` are the only + plotter options passed to :attr:`drawer`. This is done because :class:`BasePlotter` distinguishes + between plotter options and plot-options. + """ + ## Axis, subplots, and style + if self.options.axis: + self.drawer.set_options(axis=self.options.axis) + if self.options.subplots: + self.drawer.set_options(subplots=self.options.subplots) + self.drawer.set_plot_options(custom_style=self.options.style) + + ## Plot Options + # HACK: We need to accesses internal variables of the Options class, which is not good practice. + # Options._fields are dictionaries. However, given we are accessing an internal variable, this + # may change in the future. + _drawer_plot_options = self.drawer.plot_options._fields + _plotter_plot_options = self.plot_options._fields + + # If an option exists in drawer.plot_options AND in self.plot_options, set the drawers + # plot-option value to that from the plotter. + for opt_key in _drawer_plot_options: + if opt_key in _plotter_plot_options: + _drawer_plot_options[opt_key] = _plotter_plot_options[opt_key] + + # Use drawer.set_plot_options so plot-options are serialized. + self.drawer.set_plot_options(**_drawer_plot_options) + def config(self) -> Dict: """Return the config dictionary for this drawing.""" - # TODO: Figure out how self._drawer:BaseDrawer be serialized? + # FIXME: Figure out how self._drawer should be serialized? options = dict((key, getattr(self._options, key)) for key in self._set_options) + plot_options = dict( + (key, getattr(self._plot_options, key)) for key in self._set_plot_options + ) return { "cls": type(self), "options": options, + "plot_options": plot_options, } def __json_encode__(self): @@ -242,8 +439,11 @@ def __json_encode__(self): @classmethod def __json_decode__(cls, value): - # TODO: Figure out how self._drawer:BaseDrawer be serialized? - instance = cls() + # FIXME: Figure out how self._drawer:BaseDrawer be serialized? + drawer = value["drawer"] + instance = cls(drawer) if "options" in value: instance.set_options(**value["options"]) + if "plot_options" in value: + instance.set_plot_options(**value["plot_options"]) return instance diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index e8b8c70741..ace012107d 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -1,17 +1,48 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +"""Plotter for curve-fits, specifically from :class:`CurveAnalysis`.""" from typing import List from qiskit_experiments.framework import Options -from qiskit_experiments.visualization import BaseDrawer from .base_plotter import BasePlotter class CurvePlotter(BasePlotter): - def __init__(self, drawer: BaseDrawer): - super().__init__(drawer) + """A plotter class to plot results from :class:`CurveAnalysis`. + + :class:`CurvePlotter` plots results from curve-fits, which includes: + Raw results as a scatter plot. + Processed results with standard-deviations/confidence intervals. + Interpolated fit-results from the curve analysis. + Confidence interval for the fit-results. + A report on the performance of the fit. + """ @classmethod - def _default_series_data_keys(cls) -> List[str]: + def expected_series_data_keys(cls) -> List[str]: + """Returns the expected series data-keys supported by this plotter. + + Data Keys: + x: X-values for raw results. + y: Y-values for raw results. Goes with ``x``. + x_formatted: X-values for processed results. + y_formatted: Y-values for processed results. Goes with ``x_formatted``. + y_formatted_err: Error in ``y_formatted``, to be plotted as error-bars. + x_interp: Interpolated X-values for a curve-fit. + y_mean: Y-values corresponding to the fit for ``x_interp`` X-values. + sigmas: The standard-deviations of the fit for each X-value in ``x_interp``. + This data-key relates to the option ``plot_sigma``. + """ return [ "x", "y", @@ -21,12 +52,24 @@ def _default_series_data_keys(cls) -> List[str]: "x_interp", "y_mean", "sigmas", - "fit_report", + ] + + @classmethod + def expected_figure_data_keys(cls) -> List[str]: + """Returns the expected figures data-keys supported by this plotter. + + Data Keys: + report_text: A string containing any fit report information to be drawn in a box. + The style and position of the report is controlled by ``report_rpos`` and + ``report_text_size`` style parameters in :class:`PlotStyle`. + """ + return [ + "report_text", ] @classmethod def _default_options(cls) -> Options: - """Return curve-plotter specific default plotting options. + """Return curve-plotter specific default plotter options. Plot Options: plot_sigma (List[Tuple[float, float]]): A list of two number tuples @@ -41,6 +84,7 @@ def _default_options(cls) -> Options: return options def _plot_figure(self): + """Plots a curve-fit figure.""" for ser in self.series: # Scatter plot if self.data_exists_for(ser, ["x", "y"]): @@ -55,7 +99,7 @@ def _plot_figure(self): # Line plot for fit if self.data_exists_for(ser, ["x_interp", "y_mean"]): x, y = self.data_for(ser, ["x_interp", "y_mean"]) - self.drawer.draw_fit_line(x, y, ser) + self.drawer.draw_line(x, y, ser) # Confidence interval plot if self.data_exists_for(ser, ["x_interp", "y_mean", "sigmas"]): @@ -70,6 +114,6 @@ def _plot_figure(self): ) # Fit report - if "fit_report" in self.figure_data: - fit_report_description = self.figure_data["fit_report"] - self.drawer.draw_fit_report(fit_report_description) + if "report_text" in self.figure_data: + report_text = self.figure_data["report_text"] + self.drawer.draw_report(report_text) diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index e27923d2e5..1775d5319f 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -12,16 +12,20 @@ """ Configurable stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. """ +from copy import copy from typing import Tuple + from qiskit_experiments.framework import Options -from copy import copy class PlotStyle(Options): """A stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`, and must not be confused - with :class:`~qiskit_experiments.visualization.fit_result_plotters.PlotterStyle`. The default style for Qiskit Experiments is defined in :meth:`default_style`. + with :class:`~qiskit_experiments.visualization.fit_result_plotters.PlotterStyle`. The default style + for Qiskit Experiments is defined in :meth:`default_style`. :class:`PlotStyle` subclasses + :class:`Options` and has a similar interface. Extra helper methods are included to merge and update + instances of :class:`PlotStyle`: :meth:`merge` and :meth:`update` respectively. """ @classmethod @@ -31,6 +35,9 @@ def default_style(cls) -> "PlotStyle": Returns: PlotStyle: The default plot style used by Qiskit Experiments. """ + # pylint: disable = attribute-defined-outside-init + # We disable attribute-defined-outside-init so we can set style parameters outside of the + # initialization call and thus include type hints. new = cls() # size of figure (width, height) new.figsize: Tuple[int, int] = (8, 5) @@ -45,10 +52,10 @@ def default_style(cls) -> "PlotStyle": new.axis_label_size: int = 16 # relative position of fit report - new.fit_report_rpos: Tuple[float, float] = (0.6, 0.95) + new.report_rpos: Tuple[float, float] = (0.6, 0.95) # size of fit report text - new.fit_report_text_size: int = 14 + new.report_text_size: int = 14 return new @@ -62,7 +69,7 @@ def update(self, other_style: "PlotStyle"): @classmethod def merge(cls, style1: "PlotStyle", style2: "PlotStyle") -> "PlotStyle": - """Merges two PlotStyle instances. + """Merge two PlotStyle instances into a new instance. The styles are merged such that style fields in ``style2`` have priority. i.e., a field ``foo``, defined in both input styles, will have the value :code-block:`style2.foo` in the output. @@ -73,7 +80,15 @@ def merge(cls, style1: "PlotStyle", style2: "PlotStyle") -> "PlotStyle": Returns: PlotStyle: A plot style containing the combined fields of both input styles. + + Raises: + RuntimeError: If either of the input styles is not of type :class:`PlotStyle`. """ + if not isinstance(style1, PlotStyle) or not isinstance(style2, PlotStyle): + raise RuntimeError( + "Incorrect style type for PlotStyle.merge: expected PlotStyle but got " + f"{type(style1).__name__} and {type(style2).__name__}" + ) new_style = copy(style1) new_style.update(style2) return new_style diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py new file mode 100644 index 0000000000..accb7144f1 --- /dev/null +++ b/test/visualization/mock_drawer.py @@ -0,0 +1,99 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Mock drawer for testing. +""" + +from typing import Optional, Sequence + +from qiskit_experiments.visualization import BaseDrawer, PlotStyle + + +class MockDrawer(BaseDrawer): + """Mock drawer for visualization tests. + + Most methods of this class do nothing. + """ + + @property + def figure(self): + """Does nothing.""" + pass + + @classmethod + def _default_style(cls) -> PlotStyle: + """Default style. + + Style Param: + overwrite_param: A test style parameter to be overwritten by a test. + """ + style = super()._default_style() + style.overwrite_param = "overwrite_param" + return style + + def initialize_canvas(self): + """Does nothing.""" + pass + + def format_canvas(self): + """Does nothing.""" + pass + + def draw_raw_data( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Does nothing.""" + pass + + def draw_formatted_data( + self, + x_data: Sequence[float], + y_data: Sequence[float], + y_err_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Does nothing.""" + pass + + def draw_line( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Does nothing.""" + pass + + def draw_confidence_interval( + self, + x_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Does nothing.""" + pass + + def draw_report( + self, + description: str, + **options, + ): + """Does nothing.""" + pass diff --git a/test/visualization/mock_plotter.py b/test/visualization/mock_plotter.py new file mode 100644 index 0000000000..c31abea64f --- /dev/null +++ b/test/visualization/mock_plotter.py @@ -0,0 +1,71 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Mock plotter for testing. +""" + +from typing import List +from qiskit_experiments.visualization import BasePlotter, BaseDrawer + + +class MockPlotter(BasePlotter): + """Mock plotter for visualization tests. + + If :attr:`plotting_enabled` is true, :class:`MockPlotter` will plot formatted data. + :attr:`plotting_enabled` defaults to false as most test usage of the class uses :class:`MockDrawer`, + which doesn't generate a useful figure. + """ + + def __init__(self, drawer: BaseDrawer, plotting_enabled: bool = False): + """Construct a mock plotter instance for testing. + + Args: + drawer: The drawer to use for plotting + plotting_enabled: Whether to actually plot using :attr:`drawer` or not. Defaults to False. + """ + super().__init__(drawer) + self._plotting_enabled = plotting_enabled + + @property + def plotting_enabled(self): + """Whether :class:`MockPlotter` should plot data. + + Defaults to False during construction. + """ + return self._plotting_enabled + + def _plot_figure(self): + """Plots a figure if :attr:`plotting_enabled` is True. + + If :attr:`plotting_enabled` is True, :class:`MockPlotter` calls + :meth:`~BaseDrawer.draw_formatted_data` for a series titled ``seriesA`` with ``x``, ``y``, and + ``z`` data-keys assigned to the x and y values and the y-error/standard deviation respectively. + If :attr:`drawer` generates a figure, then :meth:`figure` should return a scatterplot figure with + error-bars. + """ + if self.plotting_enabled: + self.drawer.draw_formatted_data(*self.data_for("seriesA", ["x", "y", "z"]), "seriesA") + + @classmethod + def expected_series_data_keys(cls) -> List[str]: + """Dummy data-keys. + + Data Keys: + x: Dummy value. + y: Dummy value. + z: Dummy value. + """ + return ["x", "y", "z"] + + @classmethod + def expected_figure_data_keys(cls) -> List[str]: + return [] diff --git a/test/visualization/test_plotter.py b/test/visualization/test_plotter.py new file mode 100644 index 0000000000..db6aee1090 --- /dev/null +++ b/test/visualization/test_plotter.py @@ -0,0 +1,87 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Test integration of plotter. +""" + +from copy import copy +from test.base import QiskitExperimentsTestCase + +from .mock_drawer import MockDrawer +from .mock_plotter import MockPlotter + + +class TestPlotter(QiskitExperimentsTestCase): + """Test the generic plotter interface.""" + + def test_warn_unknown_data_key(self): + """Test that setting an unknown data-key raises a warning.""" + plotter = MockPlotter(MockDrawer()) + + # TODO: Add check for no-warnings. assertNoWarns only available from Python 3.10+ + + # An unknown data-key must raise a warning if it is used to set series data. + with self.assertWarns(UserWarning): + plotter.set_series_data("dummy_series", unknown_data_key=[0, 1, 2]) + + def test_series_data_end_to_end(self): + """Test end-to-end for series data setting and retrieving.""" + plotter = MockPlotter(MockDrawer()) + + series_data = { + "seriesA": { + "x": 0, + "y": "1", + "z": [2], + }, + "seriesB": { + "x": 1, + "y": 0.5, + }, + } + unexpected_data = ["a", True, 0] + expected_series_data = copy(series_data) + expected_series_data["seriesA"]["unexpected_data"] = unexpected_data + + for series, data in series_data.items(): + plotter.set_series_data(series, **data) + + with self.assertWarns(UserWarning): + plotter.set_series_data("seriesA", unexpected_data=unexpected_data) + + for series, data in expected_series_data.items(): + self.assertTrue(series in plotter.series) + self.assertTrue(plotter.data_exists_for(series, list(data.keys()))) + for data_key, value in data.items(): + # Must index [0] for `data_for` as it returns a tuple. + self.assertEqual(value, plotter.data_for(series, data_key)[0]) + + def test_figure_data_end_to_end(self): + """Test end-to-end for figure data setting and retrieval.""" + plotter = MockPlotter(MockDrawer()) + + expected_figure_data = { + "report_text": "Lorem ipsum", + "another_data_key": 3e9, + } + + plotter.set_figure_data(**expected_figure_data) + + # Check if figure data has been stored and can be retrieved + for key, expected_value in expected_figure_data.items(): + actual_value = plotter.figure_data[key] + self.assertEqual( + expected_value, + actual_value, + msg=f"Actual figure data value for {key} data-key is not as expected: {actual_value} " + f"(actual) vs {expected_value} (expected)", + ) diff --git a/test/visualization/test_plotter_drawer.py b/test/visualization/test_plotter_drawer.py new file mode 100644 index 0000000000..1959094ba6 --- /dev/null +++ b/test/visualization/test_plotter_drawer.py @@ -0,0 +1,79 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Test integration of plotters and drawers. +""" + +from copy import copy +from test.base import QiskitExperimentsTestCase + +from qiskit_experiments.visualization import PlotStyle + +from .mock_drawer import MockDrawer +from .mock_plotter import MockPlotter + + +class TestPlotterAndDrawerIntegration(QiskitExperimentsTestCase): + """Test plotter and drawer integration.""" + + def test_plot_options(self): + """Test copying and passing of plot-options between plotter and drawer.""" + plotter = MockPlotter(MockDrawer()) + + # Expected options + expected_plot_options = copy(plotter.drawer.plot_options) + expected_plot_options.xlabel = "xlabel" + expected_plot_options.ylabel = "ylabel" + expected_plot_options.figure_title = "figure_title" + + # Expected style + expected_custom_style = PlotStyle( + test_param="test_param", overwrite_param="new_overwrite_param_value" + ) + expected_full_style = PlotStyle.merge( + plotter.drawer.options.default_style, expected_custom_style + ) + expected_plot_options.custom_style = expected_custom_style + + # Set dummy plot options to update + plotter.set_plot_options( + xlabel="xlabel", + ylabel="ylabel", + figure_title="figure_title", + non_drawer_options="should not be set", + ) + plotter.set_options( + style=PlotStyle(test_param="test_param", overwrite_param="new_overwrite_param_value") + ) + + # Call plotter.figure() to force passing of plot_options to drawer + plotter.figure() + + ## Test values + # Check style as this is a more detailed plot-option than others. + self.assertEqual(expected_full_style, plotter.drawer.style) + + # Check individual plot-options. + for key, value in expected_plot_options._fields.items(): + self.assertEqual( + getattr(plotter.drawer.plot_options, key), + value, + msg=f"Expected equal values for plot option '{key}'", + ) + + # Coarse equality check of plot_options + self.assertEqual( + expected_plot_options, + plotter.drawer.plot_options, + msg=rf"expected_plot_options = {expected_plot_options}\nactual_plot_options =" + rf"{plotter.drawer.plot_options}", + ) diff --git a/test/visualization/test_plotter_mpldrawer.py b/test/visualization/test_plotter_mpldrawer.py new file mode 100644 index 0000000000..2301a0119a --- /dev/null +++ b/test/visualization/test_plotter_mpldrawer.py @@ -0,0 +1,39 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Test integration of plotter with Matplotlib drawer. +""" + +from test.base import QiskitExperimentsTestCase + +import matplotlib +from qiskit_experiments.visualization import MplDrawer + +from .mock_plotter import MockPlotter + + +class TestPlotterAndMplDrawer(QiskitExperimentsTestCase): + """Test generic plotter with Matplotlib drawer.""" + + def test_end_to_end(self): + """Test whether plotter with MplDrawer returns a figure.""" + plotter = MockPlotter(MplDrawer()) + plotter.set_series_data( + "seriesA", x=[0, 1, 2, 3, 4, 5], y=[0, 1, 0, 1, 0, 1], z=[0.1, 0.1, 0.3, 0.4, 0.0] + ) + fig = plotter.figure() + + # Expect something + self.assertTrue(fig is not None) + + # Expect a specific type + self.assertTrue(isinstance(fig, matplotlib.pyplot.Figure)) diff --git a/test/visualization/test_style.py b/test/visualization/test_style.py index 2a6aaef6d3..3f1f01cb27 100644 --- a/test/visualization/test_style.py +++ b/test/visualization/test_style.py @@ -13,10 +13,11 @@ Test visualization plotter. """ -from typing import Tuple +from copy import copy from test.base import QiskitExperimentsTestCase +from typing import Tuple + from qiskit_experiments.visualization import PlotStyle -from copy import copy class TestPlotStyle(QiskitExperimentsTestCase): @@ -56,8 +57,8 @@ def test_default_contains_necessary_fields(self): "legend_loc", "tick_label_size", "axis_label_size", - "fit_report_rpos", - "fit_report_text_size", + "report_rpos", + "report_text_size", ] for field in expected_not_none_fields: self.assertIsNotNone(getattr(default, field)) @@ -93,4 +94,6 @@ def test_field_access(self): # This should throw as we haven't assigned y with self.assertRaises(AttributeError): + # Disable pointless-statement as accessing style fields can raise an exception. + # pylint: disable = pointless-statement dummy_style.y From 6bb2142c95ccdcb4c777ad96971bebd7b5d8a877 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 15 Sep 2022 16:21:06 +0200 Subject: [PATCH 11/45] Update analysis classes to use new plotter and drawer visualization modules --- .../curve_analysis/base_curve_analysis.py | 2 +- .../curve_analysis/composite_curve_analysis.py | 12 +++--------- .../curve_analysis/curve_analysis.py | 18 ++++++++---------- .../standard_analysis/bloch_trajectory.py | 2 +- .../error_amplification_analysis.py | 2 +- .../standard_analysis/gaussian.py | 2 +- .../standard_analysis/resonance.py | 2 +- .../analysis/cr_hamiltonian_analysis.py | 15 ++++++++++----- .../characterization/analysis/drag_analysis.py | 2 +- .../analysis/ramsey_xy_analysis.py | 2 +- .../resonator_spectroscopy_analysis.py | 9 +++++---- .../characterization/analysis/t1_analysis.py | 4 ++-- .../analysis/t2hahn_analysis.py | 2 +- .../analysis/t2ramsey_analysis.py | 2 +- .../library/characterization/rabi.py | 2 +- .../randomized_benchmarking/rb_analysis.py | 2 +- 16 files changed, 39 insertions(+), 41 deletions(-) diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index b0fa8de5e3..14b68cef2f 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -23,7 +23,7 @@ from qiskit_experiments.data_processing import DataProcessor from qiskit_experiments.data_processing.processor_library import get_processor from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData -from qiskit_experiments.visualization import MplDrawer, BaseDrawer, BasePlotter, CurvePlotter +from qiskit_experiments.visualization import MplDrawer, BasePlotter, CurvePlotter from .curve_data import CurveData, ParameterRepr, CurveFitResult PARAMS_ENTRY_PREFIX = "@Parameters_" diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index f7007bb722..a598f97c62 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -22,7 +22,7 @@ from uncertainties import unumpy as unp, UFloat from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options -from qiskit_experiments.visualization import MplDrawer,CurvePlotter,BasePlotter +from qiskit_experiments.visualization import MplDrawer, CurvePlotter, BasePlotter from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties @@ -232,10 +232,6 @@ def _run_analysis( analysis_results = [] - # Initialize canvas - # if self.options.plot: - # self.drawer.initialize_canvas() - fit_dataset = {} for analysis in self._analyses: analysis._initialize(experiment_data) @@ -319,7 +315,7 @@ def _run_analysis( # Draw confidence intervals with different n_sigma sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): - self.plotter.set_series_data(model._name,sigmas=sigmas) + self.plotter.set_series_data(model._name, sigmas=sigmas) # Add raw data points if self.options.return_data_points: @@ -348,10 +344,8 @@ def _run_analysis( for group, fit_data in fit_dataset.items(): chisqs.append(r"reduced-$\chi^2$ = " + f"{fit_data.reduced_chisq: .4g} ({group})") report += "\n".join(chisqs) - self.plotter.set_figure_data(fit_report=report) + self.plotter.set_figure_data(report_text=report) - # Finalize canvas - # self.drawer.format_canvas() return analysis_results, [self.plotter.figure()] return analysis_results, [] diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index deba467624..5c8a0e76c4 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -140,7 +140,7 @@ def __init__( ) # pylint: disable=no-member models = [] - plot_options = {} + series_params = {} for series_def in self.__series__: models.append( lmfit.Model( @@ -149,12 +149,12 @@ def __init__( data_sort_key=series_def.filter_kwargs, ) ) - plot_options[series_def.name] = { + series_params[series_def.name] = { "color": series_def.plot_color, "symbol": series_def.plot_symbol, "canvas": series_def.canvas, } - self.plotter.set_options(plot_options=plot_options) + self.plotter.set_plot_options(series_params=series_params) self._models = models or [] self._name = name or self.__class__.__name__ @@ -467,10 +467,6 @@ def _run_analysis( self._initialize(experiment_data) analysis_results = [] - # # Initialize canvas - # if self.options.plot: - # self.drawer.initialize_canvas() - # Run data processing processed_data = self._run_data_processing( raw_data=experiment_data.data(), @@ -564,7 +560,10 @@ def _run_analysis( # Draw confidence intervals with different n_sigma sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): - self.plotter.set_series_data(model._name,sigmas=sigmas,) + self.plotter.set_series_data( + model._name, + sigmas=sigmas, + ) # Write fitting report report_description = "" @@ -572,7 +571,7 @@ def _run_analysis( if isinstance(res.value, (float, UFloat)): report_description += f"{analysis_result_to_repr(res)}\n" report_description += r"reduced-$\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" - self.plotter.set_figure_data(fit_report=report_description) + self.plotter.set_figure_data(report_text=report_description) # Add raw data points if self.options.return_data_points: @@ -582,7 +581,6 @@ def _run_analysis( # Finalize plot if self.options.plot: - # self.drawer.format_canvas() return analysis_results, [self.plotter.figure()] return analysis_results, [] diff --git a/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py b/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py index e62a718649..c250c86b9c 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py @@ -138,7 +138,7 @@ def _default_options(cls): input_key="counts", data_actions=[dp.Probability("1"), dp.BasisExpectationValue()], ) - default_options.plotter.drawer.set_options( + default_options.plotter.set_plot_options( xlabel="Flat top width", ylabel="Pauli expectation values", xval_unit="s", diff --git a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py index e486a66421..dc59b4864f 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py @@ -105,7 +105,7 @@ def _default_options(cls): considered as good. Defaults to :math:`\pi/2`. """ default_options = super()._default_options() - default_options.plotter.drawer.set_options( + default_options.plotter.set_plot_options( xlabel="Number of gates (n)", ylabel="Population", ylim=(0, 1.0), diff --git a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py index 07d9cc638d..370d318f7f 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py @@ -76,7 +76,7 @@ def __init__( @classmethod def _default_options(cls) -> Options: options = super()._default_options() - options.plotter.drawer.set_options( + options.plotter.set_plot_options( xlabel="Frequency", ylabel="Signal (arb. units)", xval_unit="Hz", diff --git a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py index 7fa5fc581e..a902643f4a 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py @@ -76,7 +76,7 @@ def __init__( @classmethod def _default_options(cls) -> Options: options = super()._default_options() - options.plotter.drawer.set_options( + options.plotter.set_plot_options( xlabel="Frequency", ylabel="Signal (arb. units)", xval_unit="Hz", diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index 70e82dc97c..c35e2635eb 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -17,6 +17,7 @@ import qiskit_experiments.curve_analysis as curve from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.visualization import PlotStyle class CrossResonanceHamiltonianAnalysis(curve.CompositeCurveAnalysis): @@ -60,8 +61,15 @@ def __init__(self): def _default_options(cls): """Return the default analysis options.""" default_options = super()._default_options() - default_options.plotter.drawer.set_options( + default_options.plotter.set_options( subplots=(3, 1), + style=PlotStyle( + figsize=(8, 10), + legend_loc="lower right", + report_rpos=(0.28, -0.10), + ), + ) + default_options.plotter.set_plot_options( xlabel="Flat top width", ylabel=[ r"$\langle$X(t)$\rangle$", @@ -69,11 +77,8 @@ def _default_options(cls): r"$\langle$Z(t)$\rangle$", ], xval_unit="s", - figsize=(8, 10), - legend_loc="lower right", - fit_report_rpos=(0.28, -0.10), ylim=(-1, 1), - plot_options={ + series_params={ "x_ctrl0": {"color": "blue", "symbol": "o", "canvas": 0}, "y_ctrl0": {"color": "blue", "symbol": "o", "canvas": 1}, "z_ctrl0": {"color": "blue", "symbol": "o", "canvas": 2}, diff --git a/qiskit_experiments/library/characterization/analysis/drag_analysis.py b/qiskit_experiments/library/characterization/analysis/drag_analysis.py index 0c944311fd..00be74548f 100644 --- a/qiskit_experiments/library/characterization/analysis/drag_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/drag_analysis.py @@ -84,7 +84,7 @@ def _default_options(cls): descriptions of analysis options. """ default_options = super()._default_options() - default_options.plotter.drawer.set_options( + default_options.plotter.set_plot_options( xlabel="Beta", ylabel="Signal (arb. units)", ) diff --git a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py index d083bbd0c7..bdd89d9eda 100644 --- a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py @@ -88,7 +88,7 @@ def _default_options(cls): descriptions of analysis options. """ default_options = super()._default_options() - default_options.plotter.drawer.set_options( + default_options.plotter.set_plot_options( xlabel="Delay", ylabel="Signal (arb. units)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py index d6028a36d2..34823dbb0e 100644 --- a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py @@ -49,7 +49,8 @@ def _run_analysis( if self.options.plot_iq_data: axis = get_non_gui_ax() figure = axis.get_figure() - figure.set_size_inches(*self.plotter.drawer.options.figsize) + # TODO: Move plotting to a new IQPlotter class. + figure.set_size_inches(*self.plotter.drawer.style.figsize) iqs = [] @@ -68,12 +69,12 @@ def _run_analysis( iqs = np.vstack(iqs) axis.scatter(iqs[:, 0], iqs[:, 1], color="b") axis.set_xlabel( - "In phase [arb. units]", fontsize=self.plotter.drawer.options.axis_label_size + "In phase [arb. units]", fontsize=self.plotter.drawer.style.axis_label_size ) axis.set_ylabel( - "Quadrature [arb. units]", fontsize=self.plotter.drawer.options.axis_label_size + "Quadrature [arb. units]", fontsize=self.plotter.drawer.style.axis_label_size ) - axis.tick_params(labelsize=self.plotter.drawer.options.tick_label_size) + axis.tick_params(labelsize=self.plotter.drawer.style.tick_label_size) axis.grid(True) figures.append(figure) diff --git a/qiskit_experiments/library/characterization/analysis/t1_analysis.py b/qiskit_experiments/library/characterization/analysis/t1_analysis.py index 21c9262ea0..c00a42e8b5 100644 --- a/qiskit_experiments/library/characterization/analysis/t1_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t1_analysis.py @@ -34,7 +34,7 @@ class T1Analysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.drawer.set_options( + options.plotter.set_plot_options( xlabel="Delay", ylabel="P(1)", xval_unit="s", @@ -85,7 +85,7 @@ class T1KerneledAnalysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.drawer.set_options( + options.plotter.set_plot_options( xlabel="Delay", ylabel="Normalized Projection on the Main Axis", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py b/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py index 3f0c7424c2..532ff4a706 100644 --- a/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py @@ -34,7 +34,7 @@ class T2HahnAnalysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.drawer.set_options( + options.plotter.set_plot_options( xlabel="Delay", ylabel="P(0)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py b/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py index fb736bea2a..0d20592221 100644 --- a/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py @@ -29,7 +29,7 @@ class T2RamseyAnalysis(curve.DampedOscillationAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.drawer.set_options( + options.plotter.set_plot_options( xlabel="Delay", ylabel="P(1)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/rabi.py b/qiskit_experiments/library/characterization/rabi.py index 5a99bcd8ac..6bd3d1af12 100644 --- a/qiskit_experiments/library/characterization/rabi.py +++ b/qiskit_experiments/library/characterization/rabi.py @@ -110,7 +110,7 @@ def __init__( result_parameters=[ParameterRepr("freq", self.__outcome__)], normalization=True, ) - self.analysis.drawer.set_options( + self.analysis.plotter.set_plot_options( xlabel="Amplitude", ylabel="Signal (arb. units)", ) diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py index 7907a5afad..5cfb22ae34 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py @@ -96,7 +96,7 @@ def _default_options(cls): 2Q RB is corrected to exclude the depolarization of underlying 1Q channels. """ default_options = super()._default_options() - default_options.plotter.drawer.set_options( + default_options.plotter.set_plot_options( xlabel="Clifford Length", ylabel="P(0)", ) From 9f17386ca45c023859130e9cde31422ff842419e Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 15 Sep 2022 16:23:13 +0200 Subject: [PATCH 12/45] Fix bugs in BasePlotter and CurveAnalysis test --- qiskit_experiments/visualization/plotters/base_plotter.py | 4 +--- test/curve_analysis/test_baseclass.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index b64592bb69..8c22c332b4 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -209,9 +209,7 @@ def set_figure_data(self, **data_kwargs): # Warn if any data-keys are not expected. unknown_data_keys = [ - data_key - for data_key in data_kwargs - if data_key not in self.expected_figure_data_keys()() + data_key for data_key in data_kwargs if data_key not in self.expected_figure_data_keys() ] for unknown_data_key in unknown_data_keys: warnings.warn( diff --git a/test/curve_analysis/test_baseclass.py b/test/curve_analysis/test_baseclass.py index f04d634deb..fd8033ff25 100644 --- a/test/curve_analysis/test_baseclass.py +++ b/test/curve_analysis/test_baseclass.py @@ -205,7 +205,7 @@ class InvalidClass: analysis.set_options(data_processor=InvalidClass()) with self.assertRaises(TypeError): - analysis.set_options(curve_plotter=InvalidClass()) + analysis.set_options(plotter=InvalidClass()) def test_end_to_end_single_function(self): """Integration test for single function.""" From c40594ceb307d71839b75b735a5188adc5fca213 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Fri, 16 Sep 2022 11:35:11 +0200 Subject: [PATCH 13/45] Add serialization test for plotter and nested drawer --- .../visualization/drawers/base_drawer.py | 11 +- .../visualization/plotters/base_plotter.py | 12 +- qiskit_experiments/visualization/style.py | 26 +++- test/visualization/test_mpldrawer.py | 43 +++++++ test/visualization/test_plotter_drawer.py | 120 +++++++++++++++--- 5 files changed, 187 insertions(+), 25 deletions(-) create mode 100644 test/visualization/test_mpldrawer.py diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 0631f86ca5..02ea307ad5 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -307,8 +307,15 @@ def figure(self): def config(self) -> Dict: """Return the config dictionary for this drawer.""" options = dict((key, getattr(self._options, key)) for key in self._set_options) + plot_options = dict( + (key, getattr(self._plot_options, key)) for key in self._set_plot_options + ) - return {"cls": type(self), "options": options} + return { + "cls": type(self), + "options": options, + "plot_options": plot_options, + } def __json_encode__(self): return self.config() @@ -318,4 +325,6 @@ def __json_decode__(cls, value): instance = cls() if "options" in value: instance.set_options(**value["options"]) + if "plot_options" in value: + instance.set_plot_options(**value["plot_options"]) return instance diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 8c22c332b4..0bec63d5e4 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -420,16 +420,17 @@ def _initialize_drawer(self): def config(self) -> Dict: """Return the config dictionary for this drawing.""" - # FIXME: Figure out how self._drawer should be serialized? options = dict((key, getattr(self._options, key)) for key in self._set_options) plot_options = dict( (key, getattr(self._plot_options, key)) for key in self._set_plot_options ) + drawer = self.drawer.__json_encode__() return { "cls": type(self), "options": options, "plot_options": plot_options, + "drawer": drawer, } def __json_encode__(self): @@ -437,8 +438,13 @@ def __json_encode__(self): @classmethod def __json_decode__(cls, value): - # FIXME: Figure out how self._drawer:BaseDrawer be serialized? - drawer = value["drawer"] + ## Process drawer as it's needed to create a plotter + drawer_values = value["drawer"] + # We expect a subclass of BaseDrawer + drawer_cls: BaseDrawer = drawer_values["cls"] + drawer = drawer_cls.__json_decode__(drawer_values) + + # Create plotter instance instance = cls(drawer) if "options" in value: instance.set_options(**value["options"]) diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index 1775d5319f..4d8c750ebd 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -13,7 +13,7 @@ Configurable stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. """ from copy import copy -from typing import Tuple +from typing import Dict, Tuple from qiskit_experiments.framework import Options @@ -92,3 +92,27 @@ def merge(cls, style1: "PlotStyle", style2: "PlotStyle") -> "PlotStyle": new_style = copy(style1) new_style.update(style2) return new_style + + def config(self) -> Dict: + """Return the config dictionary for this PlotStyle instance. + + .. Note:: + Validators are not currently supported + + Returns: + dict: A dictionary containing the config of the plot style. + """ + return { + "cls": type(self), + **self._fields, + } + + def __json_encode__(self): + return self.config() + + @classmethod + def __json_decode__(cls, value): + kwargs = value + kwargs.pop("cls") + inst = cls(**kwargs) + return inst diff --git a/test/visualization/test_mpldrawer.py b/test/visualization/test_mpldrawer.py new file mode 100644 index 0000000000..0de385e797 --- /dev/null +++ b/test/visualization/test_mpldrawer.py @@ -0,0 +1,43 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. +""" +Test Matplotlib Drawer. +""" + +from copy import copy +from test.base import QiskitExperimentsTestCase + +import matplotlib +from qiskit_experiments.visualization import MplDrawer + + +class TestMplDrawer(QiskitExperimentsTestCase): + """Test MplDrawer.""" + + def test_end_to_end(self): + """Test that MplDrawer generates something.""" + drawer = MplDrawer() + + # Draw dummy data + drawer.initialize_canvas() + drawer.draw_raw_data([0, 1, 2], [0, 1, 2], "seriesA") + drawer.draw_formatted_data([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], "seriesA") + drawer.draw_line([3, 2, 1], [1, 2, 3], "seriesB") + drawer.draw_confidence_interval([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], "seriesB") + drawer.draw_report(r"Dummy report text with LaTex $\beta$") + + # Get result + fig = drawer.figure + + # Check that + self.assertTrue(fig is not None) + self.assertTrue(isinstance(fig, matplotlib.pyplot.Figure)) diff --git a/test/visualization/test_plotter_drawer.py b/test/visualization/test_plotter_drawer.py index 1959094ba6..e6839bb10f 100644 --- a/test/visualization/test_plotter_drawer.py +++ b/test/visualization/test_plotter_drawer.py @@ -16,18 +16,84 @@ from copy import copy from test.base import QiskitExperimentsTestCase -from qiskit_experiments.visualization import PlotStyle +from qiskit_experiments.framework import Options +from qiskit_experiments.visualization import BasePlotter, PlotStyle from .mock_drawer import MockDrawer from .mock_plotter import MockPlotter +def dummy_plotter() -> BasePlotter: + """Return a MockPlotter with dummy option values. + + Returns: + BasePlotter: A dummy plotter. + """ + plotter = MockPlotter(MockDrawer()) + # Set dummy plot options to update + plotter.set_plot_options( + xlabel="xlabel", + ylabel="ylabel", + figure_title="figure_title", + non_drawer_options="should not be set", + ) + plotter.set_options( + style=PlotStyle(test_param="test_param", overwrite_param="new_overwrite_param_value") + ) + return plotter + + class TestPlotterAndDrawerIntegration(QiskitExperimentsTestCase): """Test plotter and drawer integration.""" + def assertOptionsEqual( + self, + options1: Options, + options2: Options, + msg_prefix: str = "", + only_assert_for_intersection=False, + ): + """Asserts that two options are the same by checking each individual option. + + This method is easier to read than a standard equality assertion as individual option names are + printed. + + Args: + options1: The first Options instance to check. + options2: The second Options instance to check. + msg_prefix: A prefix to add before assert messages. + only_assert_for_intersection: If True, will only check options that are in both Options + instances. Defaults to False. + """ + # Get combined field names + if only_assert_for_intersection: + fields = set(options1._fields.keys()).intersection(set(options2._fields.keys())) + else: + fields = set(options1._fields.keys()).union(set(options2._fields.keys())) + + # Check individual options. + for key in fields: + # Check if the option exists in both + self.assertTrue( + hasattr(options1, key), + msg=f"[{msg_prefix}] Expected field {key} in both, but only found in one: not in " + f"{options1}.", + ) + self.assertTrue( + hasattr(options2, key), + msg=f"[{msg_prefix}] Expected field {key} in both, but only found in one: not in " + f"{options2}.", + ) + self.assertEqual( + getattr(options1, key), + getattr(options2, key), + msg=f"[{msg_prefix}] Expected equal values for option '{key}': " + f"{getattr(options1, key),} vs {getattr(options2,key)}", + ) + def test_plot_options(self): """Test copying and passing of plot-options between plotter and drawer.""" - plotter = MockPlotter(MockDrawer()) + plotter = dummy_plotter() # Expected options expected_plot_options = copy(plotter.drawer.plot_options) @@ -39,22 +105,12 @@ def test_plot_options(self): expected_custom_style = PlotStyle( test_param="test_param", overwrite_param="new_overwrite_param_value" ) + plotter.set_options(style=expected_custom_style) expected_full_style = PlotStyle.merge( plotter.drawer.options.default_style, expected_custom_style ) expected_plot_options.custom_style = expected_custom_style - # Set dummy plot options to update - plotter.set_plot_options( - xlabel="xlabel", - ylabel="ylabel", - figure_title="figure_title", - non_drawer_options="should not be set", - ) - plotter.set_options( - style=PlotStyle(test_param="test_param", overwrite_param="new_overwrite_param_value") - ) - # Call plotter.figure() to force passing of plot_options to drawer plotter.figure() @@ -62,13 +118,9 @@ def test_plot_options(self): # Check style as this is a more detailed plot-option than others. self.assertEqual(expected_full_style, plotter.drawer.style) - # Check individual plot-options. - for key, value in expected_plot_options._fields.items(): - self.assertEqual( - getattr(plotter.drawer.plot_options, key), - value, - msg=f"Expected equal values for plot option '{key}'", - ) + # Check individual plot-options, but only the intersection as those are the ones we expect to be + # updated. + self.assertOptionsEqual(expected_plot_options, plotter.drawer.plot_options, True) # Coarse equality check of plot_options self.assertEqual( @@ -77,3 +129,31 @@ def test_plot_options(self): msg=rf"expected_plot_options = {expected_plot_options}\nactual_plot_options =" rf"{plotter.drawer.plot_options}", ) + + def test_serializable(self): + """Test that plotter is serializable.""" + original_plotter = dummy_plotter() + + def check_options(original, new): + """Verifies that ``new`` plotter has the same options as ``original`` plotter.""" + self.assertOptionsEqual(original.options, new.options, "options") + self.assertOptionsEqual(original.plot_options, new.plot_options, "plot_options") + self.assertOptionsEqual(original.drawer.options, new.drawer.options, "drawer.options") + self.assertOptionsEqual( + original.drawer.plot_options, new.drawer.plot_options, "drawer.plot_options" + ) + + ## Check that plotter, BEFORE PLOTTING, survives serialization correctly. + # HACK: A dedicated JSON encoder and decoder class would be better. + # __json___ are not typically called, instead json.dumps etc. is called + encoded = original_plotter.__json_encode__() + decoded_plotter = original_plotter.__class__.__json_decode__(encoded) + check_options(original_plotter, decoded_plotter) + + ## Check that plotter, AFTER PLOTTING, survives serialization correctly. + original_plotter.figure() + # HACK: A dedicated JSON encoder and decoder class would be better. + # __json___ are not typically called, instead json.dumps etc. is called + encoded = original_plotter.__json_encode__() + decoded_plotter = original_plotter.__class__.__json_decode__(encoded) + check_options(original_plotter, decoded_plotter) From 18330524332e7de4ea9d259487b4503f4ccce7ae Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 19 Sep 2022 11:54:10 +0200 Subject: [PATCH 14/45] Fix lint --- test/visualization/test_mpldrawer.py | 1 - test/visualization/test_plotter_drawer.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/visualization/test_mpldrawer.py b/test/visualization/test_mpldrawer.py index 0de385e797..f5042aca8d 100644 --- a/test/visualization/test_mpldrawer.py +++ b/test/visualization/test_mpldrawer.py @@ -13,7 +13,6 @@ Test Matplotlib Drawer. """ -from copy import copy from test.base import QiskitExperimentsTestCase import matplotlib diff --git a/test/visualization/test_plotter_drawer.py b/test/visualization/test_plotter_drawer.py index e6839bb10f..1bf4f4e4e5 100644 --- a/test/visualization/test_plotter_drawer.py +++ b/test/visualization/test_plotter_drawer.py @@ -51,7 +51,7 @@ def assertOptionsEqual( options1: Options, options2: Options, msg_prefix: str = "", - only_assert_for_intersection=False, + only_assert_for_intersection: bool = False, ): """Asserts that two options are the same by checking each individual option. From f9d5ab46145593ff017357e7b5a72a48839be762 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 22 Sep 2022 18:33:01 +0200 Subject: [PATCH 15/45] Add original curve_analysis.visualization module for deprecation purposes --- .../curve_analysis/visualization/__init__.py | 31 ++ .../visualization/base_drawer.py | 287 +++++++++++++ .../curve_analysis/visualization/curves.py | 186 ++++++++ .../visualization/fit_result_plotters.py | 403 ++++++++++++++++++ .../visualization/mpl_drawer.py | 398 +++++++++++++++++ .../curve_analysis/visualization/style.py | 45 ++ 6 files changed, 1350 insertions(+) create mode 100644 qiskit_experiments/curve_analysis/visualization/__init__.py create mode 100644 qiskit_experiments/curve_analysis/visualization/base_drawer.py create mode 100644 qiskit_experiments/curve_analysis/visualization/curves.py create mode 100644 qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py create mode 100644 qiskit_experiments/curve_analysis/visualization/mpl_drawer.py create mode 100644 qiskit_experiments/curve_analysis/visualization/style.py diff --git a/qiskit_experiments/curve_analysis/visualization/__init__.py b/qiskit_experiments/curve_analysis/visualization/__init__.py new file mode 100644 index 0000000000..0c85169e32 --- /dev/null +++ b/qiskit_experiments/curve_analysis/visualization/__init__.py @@ -0,0 +1,31 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +""" +Visualization functions +""" + +from enum import Enum + +from .base_drawer import BaseCurveDrawer +from .mpl_drawer import MplCurveDrawer + +from . import fit_result_plotters +from .curves import plot_scatter, plot_errorbar, plot_curve_fit +from .style import PlotterStyle + + +# pylint: disable=invalid-name +class FitResultPlotters(Enum): + """Map the plotter name to the plotters.""" + + mpl_single_canvas = fit_result_plotters.MplDrawSingleCanvas + mpl_multiv_canvas = fit_result_plotters.MplDrawMultiCanvasVstack diff --git a/qiskit_experiments/curve_analysis/visualization/base_drawer.py b/qiskit_experiments/curve_analysis/visualization/base_drawer.py new file mode 100644 index 0000000000..2534663efe --- /dev/null +++ b/qiskit_experiments/curve_analysis/visualization/base_drawer.py @@ -0,0 +1,287 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Curve drawer abstract class.""" + +from abc import ABC, abstractmethod +from typing import Dict, Sequence, Optional + +from qiskit_experiments.framework import Options + + +class BaseCurveDrawer(ABC): + """Abstract class for the serializable Qiskit Experiments curve drawer. + + A curve drawer may be implemented by different drawing backends such as matplotlib + or plotly. Sub-classes that wrap these backends by subclassing `BaseCurveDrawer` must + implement the following abstract methods. + + initialize_canvas + + This method should implement a protocol to initialize a drawing canvas + with user input ``axis`` object. Note that curve analysis drawer + supports visualization of experiment results in multiple canvases + tiled into N (row) x M (column) inset grids, which is specified in the option ``subplots``. + By default, this is N=1, M=1 and thus no inset grid will be initialized. + The data points to draw might be provided with a canvas number defined in + :attr:`SeriesDef.canvas` which defaults to ``None``, i.e. no-inset grids. + + This method should first check the drawing options for the axis object + and initialize the axis only when it is not provided by the options. + Once axis is initialized, this is set to the instance member ``self._axis``. + + format_canvas + + This method should implement a protocol to format the appearance of canvas. + Typically, it updates axis and tick labels. Note that the axis SI unit + may be specified in the drawing options. In this case, axis numbers should be + auto-scaled with the unit prefix. + + draw_raw_data + + This method is called after data processing is completed. + This method draws raw experiment data points on the canvas. + + draw_formatted_data + + This method is called after data formatting is completed. + The formatted data might be averaged over the same x values, + or smoothed by a filtering algorithm, depending on how analysis class is implemented. + This method is called with error bars of y values and the name of the curve. + + draw_fit_line + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the interpolated x and y values. + + draw_confidence_interval + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the interpolated x and a pair of y values + that represent the upper and lower bound within certain confidence interval. + This might be called multiple times with different interval sizes. + + draw_fit_report + + This method is called after fitting is completed and when there is valid fit outcome. + This method is called with the list of analysis results and the reduced chi-squared values. + The fit report should be generated to show this information on the canvas. + + """ + + def __init__(self): + self._options = self._default_options() + self._set_options = set() + self._axis = None + self._curves = list() + + @property + def options(self) -> Options: + """Return the drawing options.""" + return self._options + + @classmethod + def _default_options(cls) -> Options: + """Return default draw options. + + Draw Options: + axis (Any): Arbitrary object that can be used as a drawing canvas. + subplots (Tuple[int, int]): Number of rows and columns when the experimental + result is drawn in the multiple windows. + xlabel (Union[str, List[str]]): X-axis label string of the output figure. + If there are multiple columns in the canvas, this could be a list of labels. + ylabel (Union[str, List[str]]): Y-axis label string of the output figure. + If there are multiple rows in the canvas, this could be a list of labels. + xlim (Tuple[float, float]): Min and max value of the horizontal axis. + If not provided, it is automatically scaled based on the input data points. + ylim (Tuple[float, float]): Min and max value of the vertical axis. + If not provided, it is automatically scaled based on the input data points. + xval_unit (str): SI unit of x values. No prefix is needed here. + For example, when the x values represent time, this option will be just "s" + rather than "ms". In the output figure, the prefix is automatically selected + based on the maximum value in this axis. If your x values are in [1e-3, 1e-4], + they are displayed as [1 ms, 10 ms]. This option is likely provided by the + analysis class rather than end-users. However, users can still override + if they need different unit notation. By default, this option is set to ``None``, + and no scaling is applied. If nothing is provided, the axis numbers will be + displayed in the scientific notation. + yval_unit (str): Unit of y values. See ``xval_unit`` for details. + figsize (Tuple[int, int]): A tuple of two numbers representing the size of + the output figure (width, height). Note that this is applicable + only when ``axis`` object is not provided. If any canvas object is provided, + the figure size associated with the axis is preferentially applied. + legend_loc (str): Vertical and horizontal location of the curve legend window in + a single string separated by a space. This defaults to ``center right``. + Vertical position can be ``upper``, ``center``, ``lower``. + Horizontal position can be ``right``, ``center``, ``left``. + tick_label_size (int): Size of text representing the axis tick numbers. + axis_label_size (int): Size of text representing the axis label. + fit_report_rpos (Tuple[int, int]): A tuple of numbers showing the location of + the fit report window. These numbers are horizontal and vertical position + of the top left corner of the window in the relative coordinate + on the output figure, i.e. ``[0, 1]``. + The fit report window shows the selected fit parameters and the reduced + chi-squared value. + fit_report_text_size (int): Size of text in the fit report window. + plot_sigma (List[Tuple[float, float]]): A list of two number tuples + showing the configuration to write confidence intervals for the fit curve. + The first argument is the relative sigma (n_sigma), and the second argument is + the transparency of the interval plot in ``[0, 1]``. + Multiple n_sigma intervals can be drawn for the single curve. + plot_options (Dict[str, Dict[str, Any]]): A dictionary of plot options for each curve. + This is keyed on the model name for each curve. Sub-dictionary is expected to have + following three configurations, "canvas", "color", and "symbol"; "canvas" is the + integer index of axis (when multi-canvas plot is set), "color" is the + color of the curve, and "symbol" is the marker style of the curve for scatter plots. + figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. + """ + return Options( + axis=None, + subplots=(1, 1), + xlabel=None, + ylabel=None, + xlim=None, + ylim=None, + xval_unit=None, + yval_unit=None, + figsize=(8, 5), + legend_loc="center right", + tick_label_size=14, + axis_label_size=16, + fit_report_rpos=(0.6, 0.95), + fit_report_text_size=14, + plot_sigma=[(1.0, 0.7), (3.0, 0.3)], + plot_options={}, + figure_title=None, + ) + + def set_options(self, **fields): + """Set the drawing options. + Args: + fields: The fields to update the options + """ + self._options.update_options(**fields) + self._set_options = self._set_options.union(fields) + + @abstractmethod + def initialize_canvas(self): + """Initialize the drawing canvas.""" + + @abstractmethod + def format_canvas(self): + """Final cleanup for the canvas appearance.""" + + @abstractmethod + def draw_raw_data( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Draw raw data. + + Args: + x_data: X values. + y_data: Y values. + name: Name of this curve. + options: Valid options for the drawer backend API. + """ + + @abstractmethod + def draw_formatted_data( + self, + x_data: Sequence[float], + y_data: Sequence[float], + y_err_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Draw the formatted data that is used for fitting. + + Args: + x_data: X values. + y_data: Y values. + y_err_data: Standard deviation of Y values. + name: Name of this curve. + options: Valid options for the drawer backend API. + """ + + @abstractmethod + def draw_fit_line( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Draw fit line. + + Args: + x_data: X values. + y_data: Fit Y values. + name: Name of this curve. + options: Valid options for the drawer backend API. + """ + + @abstractmethod + def draw_confidence_interval( + self, + x_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], + name: Optional[str] = None, + **options, + ): + """Draw cofidence interval. + + Args: + x_data: X values. + y_ub: The upper boundary of Y values. + y_lb: The lower boundary of Y values. + name: Name of this curve. + options: Valid options for the drawer backend API. + """ + + @abstractmethod + def draw_fit_report( + self, + description: str, + **options, + ): + """Draw text box that shows fit reports. + + Args: + description: A string to describe the fiting outcome. + options: Valid options for the drawer backend API. + """ + + @property + @abstractmethod + def figure(self): + """Return figure object handler to be saved in the database.""" + + def config(self) -> Dict: + """Return the config dictionary for this drawing.""" + options = dict((key, getattr(self._options, key)) for key in self._set_options) + + return {"cls": type(self), "options": options} + + def __json_encode__(self): + return self.config() + + @classmethod + def __json_decode__(cls, value): + instance = cls() + if "options" in value: + instance.set_options(**value["options"]) + return instance diff --git a/qiskit_experiments/curve_analysis/visualization/curves.py b/qiskit_experiments/curve_analysis/visualization/curves.py new file mode 100644 index 0000000000..6c7ef16ec6 --- /dev/null +++ b/qiskit_experiments/curve_analysis/visualization/curves.py @@ -0,0 +1,186 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +""" +Plotting functions for experiment analysis +""" +from typing import Callable, List, Tuple, Optional +import numpy as np +from uncertainties import unumpy as unp + +from qiskit_experiments.curve_analysis.curve_data import FitData +from qiskit_experiments.framework.matplotlib import get_non_gui_ax + + +def plot_curve_fit( + func: Callable, + result: FitData, + ax=None, + num_fit_points: int = 100, + labelsize: int = 14, + grid: bool = True, + fit_uncertainty: List[Tuple[float, float]] = None, + **kwargs, +): + """Generate plot of a curve fit analysis result. + + Wraps :func:`matplotlib.pyplot.plot`. + + Args: + func: the fit function for curve_fit. + result: a fitting data set. + ax (matplotlib.axes.Axes): Optional, a matplotlib axes to add the plot to. + num_fit_points: the number of points to plot for xrange. + labelsize: label size for plot + grid: Show grid on plot. + fit_uncertainty: a list of sigma values to plot confidence interval of fit line. + **kwargs: Additional options for matplotlib.pyplot.plot + + Returns: + matplotlib.axes.Axes: the matplotlib axes containing the plot. + + Raises: + ImportError: if matplotlib is not installed. + """ + if ax is None: + ax = get_non_gui_ax() + + if fit_uncertainty is None: + fit_uncertainty = list() + elif isinstance(fit_uncertainty, tuple): + fit_uncertainty = [fit_uncertainty] + + # Default plot options + plot_opts = kwargs.copy() + if "color" not in plot_opts: + plot_opts["color"] = "blue" + if "linestyle" not in plot_opts: + plot_opts["linestyle"] = "-" + if "linewidth" not in plot_opts: + plot_opts["linewidth"] = 2 + + xmin, xmax = result.x_range + + # Plot fit data + xs = np.linspace(xmin, xmax, num_fit_points) + ys_fit_with_error = func(xs, **dict(zip(result.popt_keys, result.popt))) + + # Line + ax.plot(xs, unp.nominal_values(ys_fit_with_error), **plot_opts) + + # Confidence interval of N sigma values + stdev_arr = unp.std_devs(ys_fit_with_error) + if np.isfinite(stdev_arr).all(): + for sigma, alpha in fit_uncertainty: + ax.fill_between( + xs, + y1=unp.nominal_values(ys_fit_with_error) - sigma * stdev_arr, + y2=unp.nominal_values(ys_fit_with_error) + sigma * stdev_arr, + alpha=alpha, + color=plot_opts["color"], + ) + + # Formatting + ax.tick_params(labelsize=labelsize) + ax.grid(grid) + return ax + + +def plot_scatter( + xdata: np.ndarray, + ydata: np.ndarray, + ax=None, + labelsize: int = 14, + grid: bool = True, + **kwargs, +): + """Generate a scatter plot of xy data. + + Wraps :func:`matplotlib.pyplot.scatter`. + + Args: + xdata: xdata used for fitting + ydata: ydata used for fitting + ax (matplotlib.axes.Axes): Optional, a matplotlib axes to add the plot to. + labelsize: label size for plot + grid: Show grid on plot. + **kwargs: Additional options for :func:`matplotlib.pyplot.scatter` + + Returns: + matplotlib.axes.Axes: the matplotlib axes containing the plot. + """ + if ax is None: + ax = get_non_gui_ax() + + # Default plot options + plot_opts = kwargs.copy() + if "c" not in plot_opts: + plot_opts["c"] = "grey" + if "marker" not in plot_opts: + plot_opts["marker"] = "x" + if "alpha" not in plot_opts: + plot_opts["alpha"] = 0.8 + + # Plot data + ax.scatter(xdata, unp.nominal_values(ydata), **plot_opts) + + # Formatting + ax.tick_params(labelsize=labelsize) + ax.grid(grid) + return ax + + +def plot_errorbar( + xdata: np.ndarray, + ydata: np.ndarray, + sigma: Optional[np.ndarray] = None, + ax=None, + labelsize: int = 14, + grid: bool = True, + **kwargs, +): + """Generate an errorbar plot of xy data. + + Wraps :func:`matplotlib.pyplot.errorbar` + + Args: + xdata: xdata used for fitting + ydata: ydata used for fitting + sigma: Optional, standard deviation of ydata + ax (matplotlib.axes.Axes): Optional, a matplotlib axes to add the plot to. + labelsize: label size for plot + grid: Show grid on plot. + **kwargs: Additional options for :func:`matplotlib.pyplot.errorbar` + + Returns: + matplotlib.axes.Axes: the matplotlib axes containing the plot. + """ + if ax is None: + ax = get_non_gui_ax() + + # Default plot options + plot_opts = kwargs.copy() + if "color" not in plot_opts: + plot_opts["color"] = "red" + if "marker" not in plot_opts: + plot_opts["marker"] = "." + if "markersize" not in plot_opts: + plot_opts["markersize"] = 9 + if "linestyle" not in plot_opts: + plot_opts["linestyle"] = "None" + + # Plot data + ax.errorbar(xdata, ydata, yerr=sigma, **plot_opts) + + # Formatting + ax.tick_params(labelsize=labelsize) + ax.grid(grid) + return ax diff --git a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py new file mode 100644 index 0000000000..537c3ed759 --- /dev/null +++ b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py @@ -0,0 +1,403 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +""" +A collection of functions that draw formatted curve analysis results. + +For example, this visualization contains not only fit curves and raw data points, +but also some extra fitting information, such as fit values of some interesting parameters +and goodness of the fitting represented by chi-squared. These extra information can be +also visualized as a fit report. + +Note that plotter is a class that only has a class method to draw the image. +This is just like a function, but allows serialization via Enum. +""" + +from collections import defaultdict +from typing import List, Dict, Optional + +import uncertainties +import numpy as np +from matplotlib.ticker import FuncFormatter +from qiskit.utils import detach_prefix + +from qiskit_experiments.curve_analysis.curve_data import SeriesDef, FitData, CurveData +from qiskit_experiments.framework import AnalysisResultData +from qiskit_experiments.framework.matplotlib import get_non_gui_ax +from .curves import plot_scatter, plot_errorbar, plot_curve_fit +from .style import PlotterStyle + + +class MplDrawSingleCanvas: + """A plotter to draw a single canvas figure for fit result.""" + + @classmethod + def draw( + cls, + series_defs: List[SeriesDef], + raw_samples: List[CurveData], + fit_samples: List[CurveData], + tick_labels: Dict[str, str], + fit_data: FitData, + result_entries: List[AnalysisResultData], + style: Optional[PlotterStyle] = None, + axis: Optional["matplotlib.axes.Axes"] = None, + ) -> "pyplot.Figure": + """Create a fit result of all curves in the single canvas. + + Args: + series_defs: List of definition for each curve. + raw_samples: List of raw sample data for each curve. + fit_samples: List of formatted sample data for each curve. + tick_labels: Dictionary of axis label information. Axis units and label for x and y + value should be explained. + fit_data: fit data generated by the analysis. + result_entries: List of analysis result data entries. + style: Optional. A configuration object to modify the appearance of the figure. + axis: Optional. A matplotlib Axis object. + + Returns: + A matplotlib figure of the curve fit result. + """ + if axis is None: + axis = get_non_gui_ax() + + # update image size to experiment default + figure = axis.get_figure() + figure.set_size_inches(*style.figsize) + else: + figure = axis.get_figure() + + # draw all curves on the same canvas + for series_def, raw_samp, fit_samp in zip(series_defs, raw_samples, fit_samples): + draw_single_curve_mpl( + axis=axis, + series_def=series_def, + raw_sample=raw_samp, + fit_sample=fit_samp, + fit_data=fit_data, + style=style, + ) + + # add legend + if len(series_defs) > 1: + axis.legend(loc=style.legend_loc) + + # get axis scaling factor + for this_axis in ("x", "y"): + sub_axis = getattr(axis, this_axis + "axis") + unit = tick_labels[this_axis + "val_unit"] + label = tick_labels[this_axis + "label"] + if unit: + maxv = np.max(np.abs(sub_axis.get_data_interval())) + scaled_maxv, prefix = detach_prefix(maxv, decimal=3) + prefactor = scaled_maxv / maxv + # pylint: disable=cell-var-from-loop + sub_axis.set_major_formatter(FuncFormatter(lambda x, p: f"{x * prefactor: .3g}")) + sub_axis.set_label_text(f"{label} [{prefix}{unit}]", fontsize=style.axis_label_size) + else: + sub_axis.set_label_text(label, fontsize=style.axis_label_size) + axis.ticklabel_format(axis=this_axis, style="sci", scilimits=(-3, 3)) + + if tick_labels["xlim"]: + axis.set_xlim(tick_labels["xlim"]) + + if tick_labels["ylim"]: + axis.set_ylim(tick_labels["ylim"]) + + # write analysis report + if fit_data: + report_str = write_fit_report(result_entries) + report_str += r"Fit $\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" + + report_handler = axis.text( + *style.fit_report_rpos, + report_str, + ha="center", + va="top", + size=style.fit_report_text_size, + transform=axis.transAxes, + ) + + bbox_props = dict(boxstyle="square, pad=0.3", fc="white", ec="black", lw=1, alpha=0.8) + report_handler.set_bbox(bbox_props) + + axis.tick_params(labelsize=style.tick_label_size) + axis.grid(True) + + return figure + + +class MplDrawMultiCanvasVstack: + """A plotter to draw a vertically stacked multi canvas figure for fit result.""" + + @classmethod + def draw( + cls, + series_defs: List[SeriesDef], + raw_samples: List[CurveData], + fit_samples: List[CurveData], + tick_labels: Dict[str, str], + fit_data: FitData, + result_entries: List[AnalysisResultData], + style: Optional[PlotterStyle] = None, + axis: Optional["matplotlib.axes.Axes"] = None, + ) -> "pyplot.Figure": + """Create a fit result of all curves in the single canvas. + + Args: + series_defs: List of definition for each curve. + raw_samples: List of raw sample data for each curve. + fit_samples: List of formatted sample data for each curve. + tick_labels: Dictionary of axis label information. Axis units and label for x and y + value should be explained. + fit_data: fit data generated by the analysis. + result_entries: List of analysis result data entries. + style: Optional. A configuration object to modify the appearance of the figure. + axis: Optional. A matplotlib Axis object. + + Returns: + A matplotlib figure of the curve fit result. + """ + if axis is None: + axis = get_non_gui_ax() + + # update image size to experiment default + figure = axis.get_figure() + figure.set_size_inches(*style.figsize) + else: + figure = axis.get_figure() + + # get canvas number + n_subplots = max(series_def.canvas for series_def in series_defs) + 1 + + # use inset axis. this allows us to draw multiple canvases on a given single axis object + inset_ax_h = (1 - (0.05 * (n_subplots - 1))) / n_subplots + inset_axes = [ + axis.inset_axes( + [0, 1 - (inset_ax_h + 0.05) * n_axis - inset_ax_h, 1, inset_ax_h], + transform=axis.transAxes, + zorder=1, + ) + for n_axis in range(n_subplots) + ] + + # show x label only in the bottom canvas + for inset_axis in inset_axes[:-1]: + inset_axis.set_xticklabels([]) + inset_axes[-1].get_shared_x_axes().join(*inset_axes) + + # remove original axis frames + axis.spines.right.set_visible(False) + axis.spines.left.set_visible(False) + axis.spines.top.set_visible(False) + axis.spines.bottom.set_visible(False) + axis.set_xticks([]) + axis.set_yticks([]) + + # collect data source per canvas + plot_map = defaultdict(list) + for curve_ind, series_def in enumerate(series_defs): + plot_map[series_def.canvas].append(curve_ind) + + y_labels = tick_labels["ylabel"].split(",") + if len(y_labels) == 1: + y_labels = y_labels * n_subplots + + for ax_ind, curve_inds in plot_map.items(): + inset_axis = inset_axes[ax_ind] + + for curve_ind in curve_inds: + draw_single_curve_mpl( + axis=inset_axis, + series_def=series_defs[curve_ind], + raw_sample=raw_samples[curve_ind], + fit_sample=fit_samples[curve_ind], + fit_data=fit_data, + style=style, + ) + + # add legend to each inset axis + if len(curve_inds) > 1: + inset_axis.legend(loc=style.legend_loc) + + # format y axis tick value of each inset axis + yaxis = getattr(inset_axis, "yaxis") + unit = tick_labels["yval_unit"] + label = y_labels[ax_ind] + if unit: + maxv = np.max(np.abs(yaxis.get_data_interval())) + scaled_maxv, prefix = detach_prefix(maxv, decimal=3) + prefactor = scaled_maxv / maxv + # pylint: disable=cell-var-from-loop + yaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x * prefactor: .3g}")) + yaxis.set_label_text(f"{label} [{prefix}{unit}]", fontsize=style.axis_label_size) + else: + inset_axis.ticklabel_format(axis="y", style="sci", scilimits=(-3, 3)) + yaxis.set_label_text(label, fontsize=style.axis_label_size) + + if tick_labels["ylim"]: + inset_axis.set_ylim(tick_labels["ylim"]) + + # format x axis + xaxis = getattr(inset_axes[-1], "xaxis") + unit = tick_labels["xval_unit"] + label = tick_labels["xlabel"] + if unit: + maxv = np.max(np.abs(xaxis.get_data_interval())) + scaled_maxv, prefix = detach_prefix(maxv, decimal=3) + prefactor = scaled_maxv / maxv + # pylint: disable=cell-var-from-loop + xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x * prefactor: .3g}")) + xaxis.set_label_text(f"{label} [{prefix}{unit}]", fontsize=style.axis_label_size) + else: + axis.ticklabel_format(axis="x", style="sci", scilimits=(-3, 3)) + xaxis.set_label_text(label, fontsize=style.axis_label_size) + + if tick_labels["xlim"]: + inset_axes[-1].set_xlim(tick_labels["xlim"]) + + # write analysis report + if fit_data: + report_str = write_fit_report(result_entries) + report_str += r"Fit $\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" + + report_handler = axis.text( + *style.fit_report_rpos, + report_str, + ha="center", + va="top", + size=style.fit_report_text_size, + transform=axis.transAxes, + ) + + bbox_props = dict(boxstyle="square, pad=0.3", fc="white", ec="black", lw=1, alpha=0.8) + report_handler.set_bbox(bbox_props) + + axis.tick_params(labelsize=style.tick_label_size) + axis.grid(True) + + return figure + + +def draw_single_curve_mpl( + axis: "matplotlib.axes.Axes", + series_def: SeriesDef, + raw_sample: CurveData, + fit_sample: CurveData, + fit_data: FitData, + style: PlotterStyle, +): + """A function that draws a single curve on the given plotter canvas. + + Args: + axis: Drawer canvas. + series_def: Definition of the curve to draw. + raw_sample: Raw sample data. + fit_sample: Formatted sample data. + fit_data: Fitting parameter collection. + style: Style sheet for plotting. + """ + + # plot raw data if data is formatted + if not np.array_equal(raw_sample.y, fit_sample.y): + plot_scatter(xdata=raw_sample.x, ydata=raw_sample.y, ax=axis, zorder=0) + + # plot formatted data + if np.all(np.isnan(fit_sample.y_err)): + sigma = None + else: + sigma = np.nan_to_num(fit_sample.y_err) + + plot_errorbar( + xdata=fit_sample.x, + ydata=fit_sample.y, + sigma=sigma, + ax=axis, + label=series_def.name, + marker=series_def.plot_symbol, + color=series_def.plot_color, + zorder=1, + linestyle="", + ) + + # plot fit curve + if fit_data: + plot_curve_fit( + func=series_def.fit_func, + result=fit_data, + ax=axis, + color=series_def.plot_color, + zorder=2, + fit_uncertainty=style.plot_sigma, + ) + + +def write_fit_report(result_entries: List[AnalysisResultData]) -> str: + """A function that generates fit reports documentation from list of data. + + Args: + result_entries: List of data entries. + + Returns: + Documentation of fit reports. + """ + analysis_description = "" + + def format_val(float_val: float) -> str: + if np.abs(float_val) < 1e-3 or np.abs(float_val) > 1e3: + return f"{float_val: .4e}" + return f"{float_val: .4g}" + + for res in result_entries: + if isinstance(res.value, uncertainties.UFloat): + fitval = res.value + unit = res.extra.get("unit", None) + if unit: + # unit is defined. do detaching prefix, i.e. 1000 Hz -> 1 kHz + try: + val, val_prefix = detach_prefix(fitval.nominal_value, decimal=3) + except ValueError: + # Value is too small or too big + val = fitval.nominal_value + val_prefix = "" + val_unit = val_prefix + unit + value_repr = f"{val: .3g}" + + # write error bar if it is finite value + if fitval.std_dev is not None and np.isfinite(fitval.std_dev): + # with stderr + try: + err, err_prefix = detach_prefix(fitval.std_dev, decimal=3) + except ValueError: + # Value is too small or too big + err = fitval.std_dev + err_prefix = "" + err_unit = err_prefix + unit + if val_unit == err_unit: + # same value scaling, same prefix + value_repr += f" \u00B1 {err: .2f} {val_unit}" + else: + # different value scaling, different prefix + value_repr += f" {val_unit} \u00B1 {err: .2f} {err_unit}" + else: + # without stderr, just append unit + value_repr += f" {val_unit}" + else: + # unit is not defined. raw value formatting is performed. + value_repr = format_val(fitval.nominal_value) + if np.isfinite(fitval.std_dev): + # with stderr + value_repr += f" \u00B1 {format_val(fitval.std_dev)}" + + analysis_description += f"{res.name} = {value_repr}\n" + + return analysis_description diff --git a/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py b/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py new file mode 100644 index 0000000000..5d9dfbe65c --- /dev/null +++ b/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py @@ -0,0 +1,398 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Curve drawer for matplotlib backend.""" + +from typing import Sequence, Optional, Tuple + +import numpy as np +from matplotlib.axes import Axes +from matplotlib.figure import Figure +from matplotlib.ticker import ScalarFormatter, Formatter +from matplotlib.cm import tab10 +from matplotlib.markers import MarkerStyle + +from qiskit.utils import detach_prefix +from qiskit_experiments.framework.matplotlib import get_non_gui_ax + +from .base_drawer import BaseCurveDrawer + + +class MplCurveDrawer(BaseCurveDrawer): + """Curve drawer for MatplotLib backend.""" + + DefaultMarkers = MarkerStyle().filled_markers + DefaultColors = tab10.colors + + class PrefixFormatter(Formatter): + """Matplotlib axis formatter to detach prefix. + + If a value is, e.g., x=1000.0 and the factor is 1000, then it will be shown + as 1.0 in the ticks and its unit will be shown with the prefactor 'k' + in the axis label. + """ + + def __init__(self, factor: float): + self.factor = factor + + def __call__(self, x, pos=None): + return self.fix_minus("{:.3g}".format(x * self.factor)) + + def initialize_canvas(self): + # Create axis if empty + if not self.options.axis: + axis = get_non_gui_ax() + figure = axis.get_figure() + figure.set_size_inches(*self.options.figsize) + else: + axis = self.options.axis + + n_rows, n_cols = self.options.subplots + n_subplots = n_cols * n_rows + if n_subplots > 1: + # Add inset axis. User may provide a single axis object via the analysis option, + # while this analysis tries to draw its result in multiple canvases, + # especially when the analysis consists of multiple curves. + # Inset axis is experimental implementation of matplotlib 3.0 so maybe unstable API. + # This draws inset axes with shared x and y axis. + inset_ax_h = 1 / n_rows + inset_ax_w = 1 / n_cols + for i in range(n_rows): + for j in range(n_cols): + # x0, y0, width, height + bounds = [ + inset_ax_w * j, + 1 - inset_ax_h * (i + 1), + inset_ax_w, + inset_ax_h, + ] + sub_ax = axis.inset_axes(bounds, transform=axis.transAxes, zorder=1) + if j != 0: + # remove y axis except for most-left plot + sub_ax.set_yticklabels([]) + else: + # this axis locates at left, write y-label + if self.options.ylabel: + label = self.options.ylabel + if isinstance(label, list): + # Y label can be given as a list for each sub axis + label = label[i] + sub_ax.set_ylabel(label, fontsize=self.options.axis_label_size) + if i != n_rows - 1: + # remove x axis except for most-bottom plot + sub_ax.set_xticklabels([]) + else: + # this axis locates at bottom, write x-label + if self.options.xlabel: + label = self.options.xlabel + if isinstance(label, list): + # X label can be given as a list for each sub axis + label = label[j] + sub_ax.set_xlabel(label, fontsize=self.options.axis_label_size) + if j == 0 or i == n_rows - 1: + # Set label size for outer axes where labels are drawn + sub_ax.tick_params(labelsize=self.options.tick_label_size) + sub_ax.grid() + + # Remove original axis frames + axis.axis("off") + else: + axis.set_xlabel(self.options.xlabel, fontsize=self.options.axis_label_size) + axis.set_ylabel(self.options.ylabel, fontsize=self.options.axis_label_size) + axis.tick_params(labelsize=self.options.tick_label_size) + axis.grid() + + self._axis = axis + + def format_canvas(self): + if self._axis.child_axes: + # Multi canvas mode + all_axes = self._axis.child_axes + else: + all_axes = [self._axis] + + # Add data labels if there are multiple labels registered per sub_ax. + for sub_ax in all_axes: + _, labels = sub_ax.get_legend_handles_labels() + if len(labels) > 1: + sub_ax.legend() + + # Format x and y axis + for ax_type in ("x", "y"): + # Get axis formatter from drawing options + if ax_type == "x": + lim = self.options.xlim + unit = self.options.xval_unit + else: + lim = self.options.ylim + unit = self.options.yval_unit + + # Compute data range from auto scale + if not lim: + v0 = np.nan + v1 = np.nan + for sub_ax in all_axes: + if ax_type == "x": + this_v0, this_v1 = sub_ax.get_xlim() + else: + this_v0, this_v1 = sub_ax.get_ylim() + v0 = np.nanmin([v0, this_v0]) + v1 = np.nanmax([v1, this_v1]) + lim = (v0, v1) + + # Format axis number notation + if unit: + # If value is specified, automatically scale axis magnitude + # and write prefix to axis label, i.e. 1e3 Hz -> 1 kHz + maxv = max(np.abs(lim[0]), np.abs(lim[1])) + try: + scaled_maxv, prefix = detach_prefix(maxv, decimal=3) + prefactor = scaled_maxv / maxv + except ValueError: + prefix = "" + prefactor = 1 + + formatter = MplCurveDrawer.PrefixFormatter(prefactor) + units_str = f" [{prefix}{unit}]" + else: + # Use scientific notation with 3 digits, 1000 -> 1e3 + formatter = ScalarFormatter() + formatter.set_scientific(True) + formatter.set_powerlimits((-3, 3)) + + units_str = "" + + for sub_ax in all_axes: + if ax_type == "x": + ax = getattr(sub_ax, "xaxis") + tick_labels = sub_ax.get_xticklabels() + else: + ax = getattr(sub_ax, "yaxis") + tick_labels = sub_ax.get_yticklabels() + + if tick_labels: + # Set formatter only when tick labels exist + ax.set_major_formatter(formatter) + if units_str: + # Add units to label if both exist + label_txt_obj = ax.get_label() + label_str = label_txt_obj.get_text() + if label_str: + label_txt_obj.set_text(label_str + units_str) + + # Auto-scale all axes to the first sub axis + if ax_type == "x": + # get_shared_y_axes() is immutable from matplotlib>=3.6.0. Must use Axis.sharey() + # instead, but this can only be called once per axis. Here we call sharey on all axes in + # a chain, which should have the same effect. + if len(all_axes) > 1: + for ax1, ax2 in zip(all_axes[1:], all_axes[0:-1]): + ax1.sharex(ax2) + all_axes[0].set_xlim(lim) + else: + # get_shared_y_axes() is immutable from matplotlib>=3.6.0. Must use Axis.sharey() + # instead, but this can only be called once per axis. Here we call sharey on all axes in + # a chain, which should have the same effect. + if len(all_axes) > 1: + for ax1, ax2 in zip(all_axes[1:], all_axes[0:-1]): + ax1.sharey(ax2) + all_axes[0].set_ylim(lim) + # Add title + if self.options.figure_title is not None: + self._axis.set_title( + label=self.options.figure_title, + fontsize=self.options.axis_label_size, + ) + + def _get_axis(self, index: Optional[int] = None) -> Axes: + """A helper method to get inset axis. + + Args: + index: Index of inset axis. If nothing is provided, it returns the entire axis. + + Returns: + Corresponding axis object. + + Raises: + IndexError: When axis index is specified but no inset axis is found. + """ + if index is not None: + try: + return self._axis.child_axes[index] + except IndexError as ex: + raise IndexError( + f"Canvas index {index} is out of range. " + f"Only {len(self._axis.child_axes)} subplots are initialized." + ) from ex + else: + return self._axis + + def _get_default_color(self, name: str) -> Tuple[float, ...]: + """A helper method to get default color for the curve. + + Args: + name: Name of the curve. + + Returns: + Default color available in matplotlib. + """ + if name not in self._curves: + self._curves.append(name) + + ind = self._curves.index(name) % len(self.DefaultColors) + return self.DefaultColors[ind] + + def _get_default_marker(self, name: str) -> str: + """A helper method to get default marker for the scatter plot. + + Args: + name: Name of the curve. + + Returns: + Default marker available in matplotlib. + """ + if name not in self._curves: + self._curves.append(name) + + ind = self._curves.index(name) % len(self.DefaultMarkers) + return self.DefaultMarkers[ind] + + def draw_raw_data( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + curve_opts = self.options.plot_options.get(name, {}) + marker = curve_opts.get("symbol", self._get_default_marker(name)) + axis = curve_opts.get("canvas", None) + + draw_options = { + "color": "grey", + "marker": marker, + "alpha": 0.8, + "zorder": 2, + } + draw_options.update(**options) + self._get_axis(axis).scatter(x_data, y_data, **draw_options) + + def draw_formatted_data( + self, + x_data: Sequence[float], + y_data: Sequence[float], + y_err_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + curve_opts = self.options.plot_options.get(name, {}) + axis = curve_opts.get("canvas", None) + color = curve_opts.get("color", self._get_default_color(name)) + marker = curve_opts.get("symbol", self._get_default_marker(name)) + + draw_ops = { + "color": color, + "marker": marker, + "markersize": 9, + "alpha": 0.8, + "zorder": 4, + "linestyle": "", + } + draw_ops.update(**options) + if name: + draw_ops["label"] = name + + if not np.all(np.isfinite(y_err_data)): + y_err_data = None + self._get_axis(axis).errorbar(x_data, y_data, yerr=y_err_data, **draw_ops) + + def draw_fit_line( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + **options, + ): + curve_opts = self.options.plot_options.get(name, {}) + axis = curve_opts.get("canvas", None) + color = curve_opts.get("color", self._get_default_color(name)) + + draw_ops = { + "color": color, + "zorder": 5, + "linestyle": "-", + "linewidth": 2, + } + draw_ops.update(**options) + self._get_axis(axis).plot(x_data, y_data, **draw_ops) + + def draw_confidence_interval( + self, + x_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], + name: Optional[str] = None, + **options, + ): + curve_opts = self.options.plot_options.get(name, {}) + axis = curve_opts.get("canvas", None) + color = curve_opts.get("color", self._get_default_color(name)) + + draw_ops = { + "zorder": 3, + "alpha": 0.1, + "color": color, + } + draw_ops.update(**options) + self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops) + + def draw_fit_report( + self, + description: str, + **options, + ): + bbox_props = { + "boxstyle": "square, pad=0.3", + "fc": "white", + "ec": "black", + "lw": 1, + "alpha": 0.8, + } + bbox_props.update(**options) + + report_handler = self._axis.text( + *self.options.fit_report_rpos, + s=description, + ha="center", + va="top", + size=self.options.fit_report_text_size, + transform=self._axis.transAxes, + zorder=6, + ) + report_handler.set_bbox(bbox_props) + + @property + def figure(self) -> Figure: + """Return figure object handler to be saved in the database. + + In the MatplotLib the ``Figure`` and ``Axes`` are different object. + User can pass a part of the figure (i.e. multi-axes) to the drawer option ``axis``. + For example, a user wants to combine two different experiment results in the + same figure, one can call ``pyplot.subplots`` with two rows and pass one of the + generated two axes to each experiment drawer. Once all the experiments complete, + the user will obtain the single figure collecting all experimental results. + + Note that this method returns the entire figure object, rather than a single axis. + Thus, the experiment data saved in the database might have a figure + collecting all child axes drawings. + """ + return self._axis.get_figure() diff --git a/qiskit_experiments/curve_analysis/visualization/style.py b/qiskit_experiments/curve_analysis/visualization/style.py new file mode 100644 index 0000000000..c63e0766bb --- /dev/null +++ b/qiskit_experiments/curve_analysis/visualization/style.py @@ -0,0 +1,45 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# 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. +""" +Configurable stylesheet. +""" +import dataclasses +from typing import Tuple, List + + +@dataclasses.dataclass +class PlotterStyle: + """A stylesheet for curve analysis figure.""" + + # size of figure (width, height) + figsize: Tuple[int, int] = (8, 5) + + # legent location (vertical, horizontal) + legend_loc: str = "center right" + + # size of tick label + tick_label_size: int = 14 + + # size of axis label + axis_label_size: int = 16 + + # relative position of fit report + fit_report_rpos: Tuple[float, float] = (0.6, 0.95) + + # size of fit report text + fit_report_text_size: int = 14 + + # sigma values for confidence interval, which are the tuple of (sigma, alpha). + # the alpha indicates the transparency of the corresponding interval plot. + plot_sigma: List[Tuple[float, float]] = dataclasses.field( + default_factory=lambda: [(1.0, 0.7), (3.0, 0.3)] + ) From 492fdce05560f9e49d4c838597cc974109ba6fe9 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 12:03:44 +0200 Subject: [PATCH 16/45] Deprecate `curve_analysis.visualization` --- .../curve_analysis/visualization/__init__.py | 23 ++++------ .../visualization/base_drawer.py | 8 +++- .../curve_analysis/visualization/curves.py | 19 +++++++- .../visualization/fit_result_plotters.py | 44 +++++++++++++++++-- .../visualization/mpl_drawer.py | 14 ++++-- .../curve_analysis/visualization/style.py | 9 +++- .../visualization/drawers/mpl_drawer.py | 8 ++-- 7 files changed, 96 insertions(+), 29 deletions(-) diff --git a/qiskit_experiments/curve_analysis/visualization/__init__.py b/qiskit_experiments/curve_analysis/visualization/__init__.py index 0c85169e32..42a7c838f2 100644 --- a/qiskit_experiments/curve_analysis/visualization/__init__.py +++ b/qiskit_experiments/curve_analysis/visualization/__init__.py @@ -10,22 +10,17 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. """ -Visualization functions -""" +Deprecated Visualization Functions. -from enum import Enum +.. note:: + This module is deprecated and replaced by :mod:`qiskit_experiments.visualization`. The new + visualization module contains classes to manage drawing to a figure canvas and plotting data + obtained from an experiment or analysis. +""" +from . import fit_result_plotters from .base_drawer import BaseCurveDrawer +from .curves import plot_curve_fit, plot_errorbar, plot_scatter +from .fit_result_plotters import FitResultPlotters from .mpl_drawer import MplCurveDrawer - -from . import fit_result_plotters -from .curves import plot_scatter, plot_errorbar, plot_curve_fit from .style import PlotterStyle - - -# pylint: disable=invalid-name -class FitResultPlotters(Enum): - """Map the plotter name to the plotters.""" - - mpl_single_canvas = fit_result_plotters.MplDrawSingleCanvas - mpl_multiv_canvas = fit_result_plotters.MplDrawMultiCanvasVstack diff --git a/qiskit_experiments/curve_analysis/visualization/base_drawer.py b/qiskit_experiments/curve_analysis/visualization/base_drawer.py index 2534663efe..f4993788bc 100644 --- a/qiskit_experiments/curve_analysis/visualization/base_drawer.py +++ b/qiskit_experiments/curve_analysis/visualization/base_drawer.py @@ -13,11 +13,17 @@ """Curve drawer abstract class.""" from abc import ABC, abstractmethod -from typing import Dict, Sequence, Optional +from typing import Dict, Optional, Sequence from qiskit_experiments.framework import Options +from qiskit_experiments.warnings import deprecated_class +@deprecated_class( + "0.6", + msg="Plotting and drawing of analysis figures has been moved to the new" + "`qiskit_experiments.visualization` module.", +) class BaseCurveDrawer(ABC): """Abstract class for the serializable Qiskit Experiments curve drawer. diff --git a/qiskit_experiments/curve_analysis/visualization/curves.py b/qiskit_experiments/curve_analysis/visualization/curves.py index 6c7ef16ec6..e49ed03f87 100644 --- a/qiskit_experiments/curve_analysis/visualization/curves.py +++ b/qiskit_experiments/curve_analysis/visualization/curves.py @@ -12,14 +12,21 @@ """ Plotting functions for experiment analysis """ -from typing import Callable, List, Tuple, Optional +from typing import Callable, List, Optional, Tuple + import numpy as np from uncertainties import unumpy as unp from qiskit_experiments.curve_analysis.curve_data import FitData from qiskit_experiments.framework.matplotlib import get_non_gui_ax +from qiskit_experiments.warnings import deprecated_function +@deprecated_function( + "0.6", + msg="Plotting and drawing functionality has been moved to the new" + "`qiskit_experiments.visualization` module.", +) def plot_curve_fit( func: Callable, result: FitData, @@ -94,6 +101,11 @@ def plot_curve_fit( return ax +@deprecated_function( + "0.6", + msg="Plotting and drawing functionality has been moved to the new" + "`qiskit_experiments.visualization` module.", +) def plot_scatter( xdata: np.ndarray, ydata: np.ndarray, @@ -138,6 +150,11 @@ def plot_scatter( return ax +@deprecated_function( + "0.6", + msg="Plotting and drawing functionality has been moved to the new" + "`qiskit_experiments.visualization` module.", +) def plot_errorbar( xdata: np.ndarray, ydata: np.ndarray, diff --git a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py index 537c3ed759..fd77ce035a 100644 --- a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py +++ b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py @@ -22,20 +22,28 @@ """ from collections import defaultdict -from typing import List, Dict, Optional +from enum import Enum +from typing import Dict, List, Optional -import uncertainties import numpy as np +import uncertainties from matplotlib.ticker import FuncFormatter from qiskit.utils import detach_prefix -from qiskit_experiments.curve_analysis.curve_data import SeriesDef, FitData, CurveData +from qiskit_experiments.curve_analysis.curve_data import CurveData, FitData, SeriesDef from qiskit_experiments.framework import AnalysisResultData from qiskit_experiments.framework.matplotlib import get_non_gui_ax -from .curves import plot_scatter, plot_errorbar, plot_curve_fit +from qiskit_experiments.warnings import deprecated_class, deprecated_function + +from .curves import plot_curve_fit, plot_errorbar, plot_scatter from .style import PlotterStyle +@deprecated_class( + "0.6", + msg="Plotting and drawing of analysis figures has been moved to the new" + "`qiskit_experiments.visualization` module.", +) class MplDrawSingleCanvas: """A plotter to draw a single canvas figure for fit result.""" @@ -136,6 +144,11 @@ def draw( return figure +@deprecated_class( + "0.6", + msg="Plotting and drawing of analysis figures has been replaced with the new" + "`qiskit_experiments.visualization` module.", +) class MplDrawMultiCanvasVstack: """A plotter to draw a vertically stacked multi canvas figure for fit result.""" @@ -288,6 +301,11 @@ def draw( return figure +@deprecated_function( + "0.6", + msg="Plotting and drawing of analysis figures has been replaced with the new" + "`qiskit_experiments.visualization` module.", +) def draw_single_curve_mpl( axis: "matplotlib.axes.Axes", series_def: SeriesDef, @@ -341,6 +359,11 @@ def draw_single_curve_mpl( ) +@deprecated_function( + "0.6", + msg="Plotting and drawing of analysis figures has been replaced with the new" + "`qiskit_experiments.visualization` module.", +) def write_fit_report(result_entries: List[AnalysisResultData]) -> str: """A function that generates fit reports documentation from list of data. @@ -401,3 +424,16 @@ def format_val(float_val: float) -> str: analysis_description += f"{res.name} = {value_repr}\n" return analysis_description + + +# pylint: disable=invalid-name +@deprecated_class( + "0.6", + msg="Plotting and drawing of analysis figures has been moved to the new" + "`qiskit_experiments.visualization` module.", +) +class FitResultPlotters(Enum): + """Map the plotter name to the plotters.""" + + mpl_single_canvas = MplDrawSingleCanvas + mpl_multiv_canvas = MplDrawMultiCanvasVstack diff --git a/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py b/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py index 5d9dfbe65c..439e562255 100644 --- a/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py +++ b/qiskit_experiments/curve_analysis/visualization/mpl_drawer.py @@ -12,21 +12,27 @@ """Curve drawer for matplotlib backend.""" -from typing import Sequence, Optional, Tuple +from typing import Optional, Sequence, Tuple import numpy as np from matplotlib.axes import Axes -from matplotlib.figure import Figure -from matplotlib.ticker import ScalarFormatter, Formatter from matplotlib.cm import tab10 +from matplotlib.figure import Figure from matplotlib.markers import MarkerStyle - +from matplotlib.ticker import Formatter, ScalarFormatter from qiskit.utils import detach_prefix + from qiskit_experiments.framework.matplotlib import get_non_gui_ax +from qiskit_experiments.warnings import deprecated_class from .base_drawer import BaseCurveDrawer +@deprecated_class( + "0.6", + msg="Plotting and drawing of analysis figures has been replaced with the new" + "`qiskit_experiments.visualization` module.", +) class MplCurveDrawer(BaseCurveDrawer): """Curve drawer for MatplotLib backend.""" diff --git a/qiskit_experiments/curve_analysis/visualization/style.py b/qiskit_experiments/curve_analysis/visualization/style.py index c63e0766bb..2248ed6fc2 100644 --- a/qiskit_experiments/curve_analysis/visualization/style.py +++ b/qiskit_experiments/curve_analysis/visualization/style.py @@ -13,9 +13,16 @@ Configurable stylesheet. """ import dataclasses -from typing import Tuple, List +from typing import List, Tuple +from qiskit_experiments.warnings import deprecated_class + +@deprecated_class( + "0.6", + msg="Plotting and drawing of analysis figures has been replaced with the new" + "`qiskit_experiments.visualization` module.", +) @dataclasses.dataclass class PlotterStyle: """A stylesheet for curve analysis figure.""" diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index eec1695fbb..0347f9c379 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -12,16 +12,16 @@ """Curve drawer for matplotlib backend.""" -from typing import Sequence, Optional, Tuple +from typing import Optional, Sequence, Tuple import numpy as np from matplotlib.axes import Axes -from matplotlib.figure import Figure -from matplotlib.ticker import ScalarFormatter, Formatter from matplotlib.cm import tab10 +from matplotlib.figure import Figure from matplotlib.markers import MarkerStyle - +from matplotlib.ticker import Formatter, ScalarFormatter from qiskit.utils import detach_prefix + from qiskit_experiments.framework.matplotlib import get_non_gui_ax from .base_drawer import BaseDrawer From f6b67740171a23a725ccad4650883e02c66fdf6a Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 13:11:39 +0200 Subject: [PATCH 17/45] Remove duplicate visualization code that will be deprecated --- .../library/quantum_volume/qv_analysis.py | 2 +- qiskit_experiments/visualization/__init__.py | 38 +- qiskit_experiments/visualization/curves.py | 186 -------- .../visualization/fit_result_plotters.py | 440 ------------------ 4 files changed, 2 insertions(+), 664 deletions(-) delete mode 100644 qiskit_experiments/visualization/curves.py delete mode 100644 qiskit_experiments/visualization/fit_result_plotters.py diff --git a/qiskit_experiments/library/quantum_volume/qv_analysis.py b/qiskit_experiments/library/quantum_volume/qv_analysis.py index 47c7124a0c..c250bf9765 100644 --- a/qiskit_experiments/library/quantum_volume/qv_analysis.py +++ b/qiskit_experiments/library/quantum_volume/qv_analysis.py @@ -20,7 +20,7 @@ import numpy as np import uncertainties from qiskit_experiments.exceptions import AnalysisError -from qiskit_experiments.visualization import plot_scatter, plot_errorbar +from qiskit_experiments.curve_analysis.visualization import plot_scatter, plot_errorbar from qiskit_experiments.framework import ( BaseAnalysis, AnalysisResultData, diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index ba9452d13b..33babf490d 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -47,44 +47,8 @@ :template: autosummary/class.rst PlotStyle - -Plotting Functions -================== - -.. autosummary:: - :toctree: ../stubs/ - - plot_curve_fit - plot_errorbar - plot_scatter - -Curve Fitting Helpers -===================== - -.. autosummary:: - :toctree: ../stubs/ - :template: autosummary/class.rst - - FitResultPlotters - fit_result_plotters.MplDrawSingleCanvas - fit_result_plotters.MplDrawMultiCanvasVstack - fit_result_plotters.PlottingStyle - """ -from enum import Enum - -from .style import PlotStyle from .drawers import BaseDrawer, MplDrawer from .plotters import BasePlotter, CurvePlotter - -from . import fit_result_plotters -from .curves import plot_scatter, plot_errorbar, plot_curve_fit - - -# pylint: disable=invalid-name -class FitResultPlotters(Enum): - """Map the plotter name to the plotters.""" - - mpl_single_canvas = fit_result_plotters.MplDrawSingleCanvas - mpl_multiv_canvas = fit_result_plotters.MplDrawMultiCanvasVstack +from .style import PlotStyle diff --git a/qiskit_experiments/visualization/curves.py b/qiskit_experiments/visualization/curves.py deleted file mode 100644 index 6c7ef16ec6..0000000000 --- a/qiskit_experiments/visualization/curves.py +++ /dev/null @@ -1,186 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2021. -# -# 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. -""" -Plotting functions for experiment analysis -""" -from typing import Callable, List, Tuple, Optional -import numpy as np -from uncertainties import unumpy as unp - -from qiskit_experiments.curve_analysis.curve_data import FitData -from qiskit_experiments.framework.matplotlib import get_non_gui_ax - - -def plot_curve_fit( - func: Callable, - result: FitData, - ax=None, - num_fit_points: int = 100, - labelsize: int = 14, - grid: bool = True, - fit_uncertainty: List[Tuple[float, float]] = None, - **kwargs, -): - """Generate plot of a curve fit analysis result. - - Wraps :func:`matplotlib.pyplot.plot`. - - Args: - func: the fit function for curve_fit. - result: a fitting data set. - ax (matplotlib.axes.Axes): Optional, a matplotlib axes to add the plot to. - num_fit_points: the number of points to plot for xrange. - labelsize: label size for plot - grid: Show grid on plot. - fit_uncertainty: a list of sigma values to plot confidence interval of fit line. - **kwargs: Additional options for matplotlib.pyplot.plot - - Returns: - matplotlib.axes.Axes: the matplotlib axes containing the plot. - - Raises: - ImportError: if matplotlib is not installed. - """ - if ax is None: - ax = get_non_gui_ax() - - if fit_uncertainty is None: - fit_uncertainty = list() - elif isinstance(fit_uncertainty, tuple): - fit_uncertainty = [fit_uncertainty] - - # Default plot options - plot_opts = kwargs.copy() - if "color" not in plot_opts: - plot_opts["color"] = "blue" - if "linestyle" not in plot_opts: - plot_opts["linestyle"] = "-" - if "linewidth" not in plot_opts: - plot_opts["linewidth"] = 2 - - xmin, xmax = result.x_range - - # Plot fit data - xs = np.linspace(xmin, xmax, num_fit_points) - ys_fit_with_error = func(xs, **dict(zip(result.popt_keys, result.popt))) - - # Line - ax.plot(xs, unp.nominal_values(ys_fit_with_error), **plot_opts) - - # Confidence interval of N sigma values - stdev_arr = unp.std_devs(ys_fit_with_error) - if np.isfinite(stdev_arr).all(): - for sigma, alpha in fit_uncertainty: - ax.fill_between( - xs, - y1=unp.nominal_values(ys_fit_with_error) - sigma * stdev_arr, - y2=unp.nominal_values(ys_fit_with_error) + sigma * stdev_arr, - alpha=alpha, - color=plot_opts["color"], - ) - - # Formatting - ax.tick_params(labelsize=labelsize) - ax.grid(grid) - return ax - - -def plot_scatter( - xdata: np.ndarray, - ydata: np.ndarray, - ax=None, - labelsize: int = 14, - grid: bool = True, - **kwargs, -): - """Generate a scatter plot of xy data. - - Wraps :func:`matplotlib.pyplot.scatter`. - - Args: - xdata: xdata used for fitting - ydata: ydata used for fitting - ax (matplotlib.axes.Axes): Optional, a matplotlib axes to add the plot to. - labelsize: label size for plot - grid: Show grid on plot. - **kwargs: Additional options for :func:`matplotlib.pyplot.scatter` - - Returns: - matplotlib.axes.Axes: the matplotlib axes containing the plot. - """ - if ax is None: - ax = get_non_gui_ax() - - # Default plot options - plot_opts = kwargs.copy() - if "c" not in plot_opts: - plot_opts["c"] = "grey" - if "marker" not in plot_opts: - plot_opts["marker"] = "x" - if "alpha" not in plot_opts: - plot_opts["alpha"] = 0.8 - - # Plot data - ax.scatter(xdata, unp.nominal_values(ydata), **plot_opts) - - # Formatting - ax.tick_params(labelsize=labelsize) - ax.grid(grid) - return ax - - -def plot_errorbar( - xdata: np.ndarray, - ydata: np.ndarray, - sigma: Optional[np.ndarray] = None, - ax=None, - labelsize: int = 14, - grid: bool = True, - **kwargs, -): - """Generate an errorbar plot of xy data. - - Wraps :func:`matplotlib.pyplot.errorbar` - - Args: - xdata: xdata used for fitting - ydata: ydata used for fitting - sigma: Optional, standard deviation of ydata - ax (matplotlib.axes.Axes): Optional, a matplotlib axes to add the plot to. - labelsize: label size for plot - grid: Show grid on plot. - **kwargs: Additional options for :func:`matplotlib.pyplot.errorbar` - - Returns: - matplotlib.axes.Axes: the matplotlib axes containing the plot. - """ - if ax is None: - ax = get_non_gui_ax() - - # Default plot options - plot_opts = kwargs.copy() - if "color" not in plot_opts: - plot_opts["color"] = "red" - if "marker" not in plot_opts: - plot_opts["marker"] = "." - if "markersize" not in plot_opts: - plot_opts["markersize"] = 9 - if "linestyle" not in plot_opts: - plot_opts["linestyle"] = "None" - - # Plot data - ax.errorbar(xdata, ydata, yerr=sigma, **plot_opts) - - # Formatting - ax.tick_params(labelsize=labelsize) - ax.grid(grid) - return ax diff --git a/qiskit_experiments/visualization/fit_result_plotters.py b/qiskit_experiments/visualization/fit_result_plotters.py deleted file mode 100644 index e43811597b..0000000000 --- a/qiskit_experiments/visualization/fit_result_plotters.py +++ /dev/null @@ -1,440 +0,0 @@ -# This code is part of Qiskit. -# -# (C) Copyright IBM 2021. -# -# 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. -""" -A collection of functions that draw formatted curve analysis results. - -For example, this visualization contains not only fit curves and raw data points, -but also some extra fitting information, such as fit values of some interesting parameters -and goodness of the fitting represented by chi-squared. These extra information can be -also visualized as a fit report. - -Note that plotter is a class that only has a class method to draw the image. -This is just like a function, but allows serialization via Enum. -""" - -import dataclasses -from collections import defaultdict -from typing import Dict, List, Optional, Tuple - -import numpy as np -import uncertainties -from matplotlib.ticker import FuncFormatter -from qiskit.utils import detach_prefix -from qiskit_experiments.curve_analysis.curve_data import CurveData, FitData, SeriesDef -from qiskit_experiments.framework import AnalysisResultData -from qiskit_experiments.framework.matplotlib import get_non_gui_ax - -from .curves import plot_curve_fit, plot_errorbar, plot_scatter - - -@dataclasses.dataclass -class PlotterStyle: - """A stylesheet specific for :mod:`fit_result_plotters`. - - This style class is used by :mod:`fit_result_plotters`, but not by :class:`BasePlotter` or - :class:`BaseDrawer`. It is recommended that new code use the new :class:`BasePlotter` and - :class:`BaseDrawer` classes to plot figures and draw on a canvas. The - :mod:`qiskit_experiments.visualization` module contains a different - :class:`qiskit_experiments.visualization.PlottingStyle` class which is specific to - :class:`BasePlotter` and :class:`DrawerPlotter`. - """ - - # size of figure (width, height) - figsize: Tuple[int, int] = (8, 5) - - # legent location (vertical, horizontal) - legend_loc: str = "center right" - - # size of tick label - tick_label_size: int = 14 - - # size of axis label - axis_label_size: int = 16 - - # relative position of report - report_rpos: Tuple[float, float] = (0.6, 0.95) - - # size of report text - report_text_size: int = 14 - - # sigma values for confidence interval, which are the tuple of (sigma, alpha). - # the alpha indicates the transparency of the corresponding interval plot. - plot_sigma: List[Tuple[float, float]] = dataclasses.field( - default_factory=lambda: [(1.0, 0.7), (3.0, 0.3)] - ) - - -class MplDrawSingleCanvas: - """A plotter to draw a single canvas figure for fit result.""" - - @classmethod - def draw( - cls, - series_defs: List[SeriesDef], - raw_samples: List[CurveData], - fit_samples: List[CurveData], - tick_labels: Dict[str, str], - fit_data: FitData, - result_entries: List[AnalysisResultData], - style: Optional[PlotterStyle] = None, - axis: Optional["matplotlib.axes.Axes"] = None, - ) -> "pyplot.Figure": - """Create a fit result of all curves in the single canvas. - - Args: - series_defs: List of definition for each curve. - raw_samples: List of raw sample data for each curve. - fit_samples: List of formatted sample data for each curve. - tick_labels: Dictionary of axis label information. Axis units and label for x and y - value should be explained. - fit_data: fit data generated by the analysis. - result_entries: List of analysis result data entries. - style: Optional. A configuration object to modify the appearance of the figure. - axis: Optional. A matplotlib Axis object. - - Returns: - A matplotlib figure of the curve fit result. - """ - if axis is None: - axis = get_non_gui_ax() - - # update image size to experiment default - figure = axis.get_figure() - figure.set_size_inches(*style.figsize) - else: - figure = axis.get_figure() - - # draw all curves on the same canvas - for series_def, raw_samp, fit_samp in zip(series_defs, raw_samples, fit_samples): - draw_single_curve_mpl( - axis=axis, - series_def=series_def, - raw_sample=raw_samp, - fit_sample=fit_samp, - fit_data=fit_data, - style=style, - ) - - # add legend - if len(series_defs) > 1: - axis.legend(loc=style.legend_loc) - - # get axis scaling factor - for this_axis in ("x", "y"): - sub_axis = getattr(axis, this_axis + "axis") - unit = tick_labels[this_axis + "val_unit"] - label = tick_labels[this_axis + "label"] - if unit: - maxv = np.max(np.abs(sub_axis.get_data_interval())) - scaled_maxv, prefix = detach_prefix(maxv, decimal=3) - prefactor = scaled_maxv / maxv - # pylint: disable=cell-var-from-loop - sub_axis.set_major_formatter(FuncFormatter(lambda x, p: f"{x * prefactor: .3g}")) - sub_axis.set_label_text(f"{label} [{prefix}{unit}]", fontsize=style.axis_label_size) - else: - sub_axis.set_label_text(label, fontsize=style.axis_label_size) - axis.ticklabel_format(axis=this_axis, style="sci", scilimits=(-3, 3)) - - if tick_labels["xlim"]: - axis.set_xlim(tick_labels["xlim"]) - - if tick_labels["ylim"]: - axis.set_ylim(tick_labels["ylim"]) - - # write analysis report - if fit_data: - report_str = write_fit_report(result_entries) - report_str += r"Fit $\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" - - report_handler = axis.text( - *style.report_rpos, - report_str, - ha="center", - va="top", - size=style.fit_report_text_size, - transform=axis.transAxes, - ) - - bbox_props = dict(boxstyle="square, pad=0.3", fc="white", ec="black", lw=1, alpha=0.8) - report_handler.set_bbox(bbox_props) - - axis.tick_params(labelsize=style.tick_label_size) - axis.grid(True) - - return figure - - -class MplDrawMultiCanvasVstack: - """A plotter to draw a vertically stacked multi canvas figure for fit result.""" - - @classmethod - def draw( - cls, - series_defs: List[SeriesDef], - raw_samples: List[CurveData], - fit_samples: List[CurveData], - tick_labels: Dict[str, str], - fit_data: FitData, - result_entries: List[AnalysisResultData], - style: Optional[PlotterStyle] = None, - axis: Optional["matplotlib.axes.Axes"] = None, - ) -> "pyplot.Figure": - """Create a fit result of all curves in the single canvas. - - Args: - series_defs: List of definition for each curve. - raw_samples: List of raw sample data for each curve. - fit_samples: List of formatted sample data for each curve. - tick_labels: Dictionary of axis label information. Axis units and label for x and y - value should be explained. - fit_data: fit data generated by the analysis. - result_entries: List of analysis result data entries. - style: Optional. A configuration object to modify the appearance of the figure. - axis: Optional. A matplotlib Axis object. - - Returns: - A matplotlib figure of the curve fit result. - """ - if axis is None: - axis = get_non_gui_ax() - - # update image size to experiment default - figure = axis.get_figure() - figure.set_size_inches(*style.figsize) - else: - figure = axis.get_figure() - - # get canvas number - n_subplots = max(series_def.canvas for series_def in series_defs) + 1 - - # use inset axis. this allows us to draw multiple canvases on a given single axis object - inset_ax_h = (1 - (0.05 * (n_subplots - 1))) / n_subplots - inset_axes = [ - axis.inset_axes( - [0, 1 - (inset_ax_h + 0.05) * n_axis - inset_ax_h, 1, inset_ax_h], - transform=axis.transAxes, - zorder=1, - ) - for n_axis in range(n_subplots) - ] - - # show x label only in the bottom canvas - for inset_axis in inset_axes[:-1]: - inset_axis.set_xticklabels([]) - inset_axes[-1].get_shared_x_axes().join(*inset_axes) - - # remove original axis frames - axis.spines.right.set_visible(False) - axis.spines.left.set_visible(False) - axis.spines.top.set_visible(False) - axis.spines.bottom.set_visible(False) - axis.set_xticks([]) - axis.set_yticks([]) - - # collect data source per canvas - plot_map = defaultdict(list) - for curve_ind, series_def in enumerate(series_defs): - plot_map[series_def.canvas].append(curve_ind) - - y_labels = tick_labels["ylabel"].split(",") - if len(y_labels) == 1: - y_labels = y_labels * n_subplots - - for ax_ind, curve_inds in plot_map.items(): - inset_axis = inset_axes[ax_ind] - - for curve_ind in curve_inds: - draw_single_curve_mpl( - axis=inset_axis, - series_def=series_defs[curve_ind], - raw_sample=raw_samples[curve_ind], - fit_sample=fit_samples[curve_ind], - fit_data=fit_data, - style=style, - ) - - # add legend to each inset axis - if len(curve_inds) > 1: - inset_axis.legend(loc=style.legend_loc) - - # format y axis tick value of each inset axis - yaxis = getattr(inset_axis, "yaxis") - unit = tick_labels["yval_unit"] - label = y_labels[ax_ind] - if unit: - maxv = np.max(np.abs(yaxis.get_data_interval())) - scaled_maxv, prefix = detach_prefix(maxv, decimal=3) - prefactor = scaled_maxv / maxv - # pylint: disable=cell-var-from-loop - yaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x * prefactor: .3g}")) - yaxis.set_label_text(f"{label} [{prefix}{unit}]", fontsize=style.axis_label_size) - else: - inset_axis.ticklabel_format(axis="y", style="sci", scilimits=(-3, 3)) - yaxis.set_label_text(label, fontsize=style.axis_label_size) - - if tick_labels["ylim"]: - inset_axis.set_ylim(tick_labels["ylim"]) - - # format x axis - xaxis = getattr(inset_axes[-1], "xaxis") - unit = tick_labels["xval_unit"] - label = tick_labels["xlabel"] - if unit: - maxv = np.max(np.abs(xaxis.get_data_interval())) - scaled_maxv, prefix = detach_prefix(maxv, decimal=3) - prefactor = scaled_maxv / maxv - # pylint: disable=cell-var-from-loop - xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x * prefactor: .3g}")) - xaxis.set_label_text(f"{label} [{prefix}{unit}]", fontsize=style.axis_label_size) - else: - axis.ticklabel_format(axis="x", style="sci", scilimits=(-3, 3)) - xaxis.set_label_text(label, fontsize=style.axis_label_size) - - if tick_labels["xlim"]: - inset_axes[-1].set_xlim(tick_labels["xlim"]) - - # write analysis report - if fit_data: - report_str = write_fit_report(result_entries) - report_str += r"Fit $\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" - - report_handler = axis.text( - *style.report_rpos, - report_str, - ha="center", - va="top", - size=style.fit_report_text_size, - transform=axis.transAxes, - ) - - bbox_props = dict(boxstyle="square, pad=0.3", fc="white", ec="black", lw=1, alpha=0.8) - report_handler.set_bbox(bbox_props) - - axis.tick_params(labelsize=style.tick_label_size) - axis.grid(True) - - return figure - - -def draw_single_curve_mpl( - axis: "matplotlib.axes.Axes", - series_def: SeriesDef, - raw_sample: CurveData, - fit_sample: CurveData, - fit_data: FitData, - style: PlotterStyle, -): - """A function that draws a single curve on the given plotter canvas. - - Args: - axis: Drawer canvas. - series_def: Definition of the curve to draw. - raw_sample: Raw sample data. - fit_sample: Formatted sample data. - fit_data: Fitting parameter collection. - style: Style sheet for plotting. - """ - - # plot raw data if data is formatted - if not np.array_equal(raw_sample.y, fit_sample.y): - plot_scatter(xdata=raw_sample.x, ydata=raw_sample.y, ax=axis, zorder=0) - - # plot formatted data - if np.all(np.isnan(fit_sample.y_err)): - sigma = None - else: - sigma = np.nan_to_num(fit_sample.y_err) - - plot_errorbar( - xdata=fit_sample.x, - ydata=fit_sample.y, - sigma=sigma, - ax=axis, - label=series_def.name, - marker=series_def.plot_symbol, - color=series_def.plot_color, - zorder=1, - linestyle="", - ) - - # plot fit curve - if fit_data: - plot_curve_fit( - func=series_def.fit_func, - result=fit_data, - ax=axis, - color=series_def.plot_color, - zorder=2, - fit_uncertainty=style.plot_sigma, - ) - - -def write_fit_report(result_entries: List[AnalysisResultData]) -> str: - """A function that generates fit reports documentation from list of data. - - Args: - result_entries: List of data entries. - - Returns: - Documentation of fit reports. - """ - analysis_description = "" - - def format_val(float_val: float) -> str: - if np.abs(float_val) < 1e-3 or np.abs(float_val) > 1e3: - return f"{float_val: .4e}" - return f"{float_val: .4g}" - - for res in result_entries: - if isinstance(res.value, uncertainties.UFloat): - fitval = res.value - unit = res.extra.get("unit", None) - if unit: - # unit is defined. do detaching prefix, i.e. 1000 Hz -> 1 kHz - try: - val, val_prefix = detach_prefix(fitval.nominal_value, decimal=3) - except ValueError: - # Value is too small or too big - val = fitval.nominal_value - val_prefix = "" - val_unit = val_prefix + unit - value_repr = f"{val: .3g}" - - # write error bar if it is finite value - if fitval.std_dev is not None and np.isfinite(fitval.std_dev): - # with stderr - try: - err, err_prefix = detach_prefix(fitval.std_dev, decimal=3) - except ValueError: - # Value is too small or too big - err = fitval.std_dev - err_prefix = "" - err_unit = err_prefix + unit - if val_unit == err_unit: - # same value scaling, same prefix - value_repr += f" \u00B1 {err: .2f} {val_unit}" - else: - # different value scaling, different prefix - value_repr += f" {val_unit} \u00B1 {err: .2f} {err_unit}" - else: - # without stderr, just append unit - value_repr += f" {val_unit}" - else: - # unit is not defined. raw value formatting is performed. - value_repr = format_val(fitval.nominal_value) - if np.isfinite(fitval.std_dev): - # with stderr - value_repr += f" \u00B1 {format_val(fitval.std_dev)}" - - analysis_description += f"{res.name} = {value_repr}\n" - - return analysis_description From d506c5a92e74cccdcaaf65aaef0f9f2c5071924a Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 13:49:57 +0200 Subject: [PATCH 18/45] Edit docstrings from review feedback. Co-authored-by: Daniel J. Egger <38065505+eggerdj@users.noreply.github.com> --- qiskit_experiments/curve_analysis/composite_curve_analysis.py | 2 +- qiskit_experiments/visualization/drawers/base_drawer.py | 2 +- qiskit_experiments/visualization/plotters/curve_plotter.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index a598f97c62..d2d9882a20 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -125,7 +125,7 @@ def models(self) -> Dict[str, List[lmfit.Model]]: @property def plotter(self) -> BasePlotter: - """A short-cut for plotter instance.""" + """A short-cut to the plotter instance.""" return self._options.plotter def analyses( diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 02ea307ad5..121b5f920d 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -40,7 +40,7 @@ class BaseDrawer(ABC): format_canvas - This method should implement a protocol to format the appearance of canvas. Typically, it updates + This method formats the appearance of the canvas. Typically, it updates axis and tick labels. Note that the axis SI unit may be specified in the drawer options. In this case, axis numbers should be auto-scaled with the unit prefix. diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index ace012107d..f0e91c479f 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -76,7 +76,7 @@ def _default_options(cls) -> Options: showing the configuration to write confidence intervals for the fit curve. The first argument is the relative sigma (n_sigma), and the second argument is the transparency of the interval plot in ``[0, 1]``. - Multiple n_sigma intervals can be drawn for the single curve. + Multiple n_sigma intervals can be drawn for the same curve. """ options = super()._default_options() From 71016879b110ecaee730ba4229ba5b71e604e07e Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 14:22:56 +0200 Subject: [PATCH 19/45] Add back deprecated option `curve_drawer` TODO: Add wrapper BaseDrawer to maintain support to BaseCurveDrawer until removal of `curve_drawer`. --- .../curve_analysis/base_curve_analysis.py | 39 ++++++++++++++++++- .../composite_curve_analysis.py | 39 ++++++++++++++++++- qiskit_experiments/visualization/style.py | 5 +-- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index 14b68cef2f..f568158c5e 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -23,7 +23,8 @@ from qiskit_experiments.data_processing import DataProcessor from qiskit_experiments.data_processing.processor_library import get_processor from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData -from qiskit_experiments.visualization import MplDrawer, BasePlotter, CurvePlotter +from qiskit_experiments.warnings import deprecated_function +from qiskit_experiments.visualization import MplDrawer, BasePlotter, CurvePlotter, BaseDrawer from .curve_data import CurveData, ParameterRepr, CurveFitResult PARAMS_ENTRY_PREFIX = "@Parameters_" @@ -117,6 +118,18 @@ def plotter(self) -> BasePlotter: """A short-cut for curve plotter instance.""" return self._options.plotter + @property + @deprecated_function( + last_version="0.6", + msg="Replaced by `plotter` from the new visualization submodule.", + ) + def drawer(self) -> BaseDrawer: + """A short-cut for curve drawer instance, if set. ``None`` otherwise.""" + if hasattr(self._options, "curve_drawer"): + return self._options.curve_drawer + else: + return None + @classmethod def _default_options(cls) -> Options: """Return default analysis options. @@ -211,6 +224,30 @@ def set_options(self, **fields): ) fields["lmfit_options"] = fields.pop("curve_fitter_options") + # TODO remove this in Qiskit Experiments 0.6 + if "curve_drawer" in fields: + warnings.warn( + "The option 'curve_drawer' is replaced with 'plotter'. " + "This option will be removed in Qiskit Experiments 0.6.", + DeprecationWarning, + stacklevel=2, + ) + # Set the plotter drawer to `curve_drawer`, though it needs to be a subclass of the new class + # `BaseDrawer` from `qiskit_experiments.visualization`. + if isinstance(fields["curve_drawer"], BaseDrawer): + plotter = self.options.plotter + plotter.drawer = fields.pop("curve_drawer") + fields["plotter"] = plotter + else: + # TODO Add usage of wrapper class for backwards compatibility during deprecation period. + drawer = fields["curve_drawer"] + warnings.warn( + "Cannot set deprecated `curve_drawer` options as it is not a subclass of " + f"`BaseDrawer`: got type {type(drawer).__name__}. Doing nothing.", + DeprecationWarning, + stacklevel=2, + ) + super().set_options(**fields) @abstractmethod diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index d2d9882a20..8c03e87cf6 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -22,7 +22,8 @@ from uncertainties import unumpy as unp, UFloat from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options -from qiskit_experiments.visualization import MplDrawer, CurvePlotter, BasePlotter +from qiskit_experiments.warnings import deprecated_function +from qiskit_experiments.visualization import MplDrawer, CurvePlotter, BasePlotter, BaseDrawer from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties @@ -128,6 +129,18 @@ def plotter(self) -> BasePlotter: """A short-cut to the plotter instance.""" return self._options.plotter + @property + @deprecated_function( + last_version="0.6", + msg="Replaced by `plotter` from the new visualization submodule.", + ) + def drawer(self) -> BaseDrawer: + """A short-cut for curve drawer instance, if set. ``None`` otherwise.""" + if hasattr(self._options, "curve_drawer"): + return self._options.curve_drawer + else: + return None + def analyses( self, index: Optional[Union[str, int]] = None ) -> Union[BaseCurveAnalysis, List[BaseCurveAnalysis]]: @@ -213,6 +226,30 @@ def _default_options(cls) -> Options: return options def set_options(self, **fields): + # TODO remove this in Qiskit Experiments 0.6 + if "curve_drawer" in fields: + warnings.warn( + "The option 'curve_drawer' is replaced with 'plotter'. " + "This option will be removed in Qiskit Experiments 0.6.", + DeprecationWarning, + stacklevel=2, + ) + # Set the plotter drawer to `curve_drawer`, though it needs to be a subclass of the new class + # `BaseDrawer` from `qiskit_experiments.visualization`. + if isinstance(fields["curve_drawer"], BaseDrawer): + plotter = self.options.plotter + plotter.drawer = fields.pop("curve_drawer") + fields["plotter"] = plotter + else: + # TODO Add usage of wrapper class for backwards compatibility during deprecation period. + drawer = fields["curve_drawer"] + warnings.warn( + "Cannot set deprecated `curve_drawer` options as it is not a subclass of " + f"`BaseDrawer`: got type {type(drawer).__name__}. Doing nothing.", + DeprecationWarning, + stacklevel=2, + ) + for field in fields: if not hasattr(self.options, field): warnings.warn( diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index 4d8c750ebd..907520432b 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -21,9 +21,8 @@ class PlotStyle(Options): """A stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. - This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`, and must not be confused - with :class:`~qiskit_experiments.visualization.fit_result_plotters.PlotterStyle`. The default style - for Qiskit Experiments is defined in :meth:`default_style`. :class:`PlotStyle` subclasses + This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`. The default style for + Qiskit Experiments is defined in :meth:`default_style`. :class:`PlotStyle` subclasses :class:`Options` and has a similar interface. Extra helper methods are included to merge and update instances of :class:`PlotStyle`: :meth:`merge` and :meth:`update` respectively. """ From 94158f454ca99ed353f13b026de6ca27e3baff3c Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 16:39:42 +0200 Subject: [PATCH 20/45] Refactor BaseDrawer to be more generic and less specific to CurveAnalysis The following changes were also made: Add isort ignore comment for `qiskit_experiments.visualization` to prevent circular import. Refactor treport/text-box style parameters to be more generic. --- .../analysis/cr_hamiltonian_analysis.py | 2 +- qiskit_experiments/visualization/__init__.py | 4 +- .../visualization/drawers/base_drawer.py | 67 ++++++---- .../visualization/drawers/mpl_drawer.py | 124 +++++++++++++----- .../visualization/plotters/curve_plotter.py | 42 ++++-- qiskit_experiments/visualization/style.py | 6 +- test/visualization/mock_drawer.py | 33 +++-- test/visualization/test_mpldrawer.py | 10 +- test/visualization/test_plotter_mpldrawer.py | 1 + 9 files changed, 199 insertions(+), 90 deletions(-) diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index c35e2635eb..5a35dbf98a 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -66,7 +66,7 @@ def _default_options(cls): style=PlotStyle( figsize=(8, 10), legend_loc="lower right", - report_rpos=(0.28, -0.10), + text_box_rel_pos=(0.28, -0.10), ), ) default_options.plotter.set_plot_options( diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 33babf490d..610b1d8767 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -49,6 +49,8 @@ PlotStyle """ +# PlotStyle is imported by .drawers and .plotters. Skip PlotStyle import for isort to prevent circular +# import. +from .style import PlotStyle # isort:skip from .drawers import BaseDrawer, MplDrawer from .plotters import BasePlotter, CurvePlotter -from .style import PlotStyle diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 121b5f920d..3052c3b4ea 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -13,7 +13,7 @@ """Drawer abstract class.""" from abc import ABC, abstractmethod -from typing import Dict, Sequence, Optional +from typing import Dict, Optional, Sequence, Tuple from qiskit_experiments.framework import Options from qiskit_experiments.visualization import PlotStyle @@ -215,87 +215,110 @@ def format_canvas(self): """Final cleanup for the canvas appearance.""" @abstractmethod - def draw_raw_data( + def draw_scatter( self, x_data: Sequence[float], y_data: Sequence[float], + x_err: Optional[Sequence[float]] = None, + y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): - """Draw raw data. + """Draw scatter points, with optional error-bars. Args: x_data: X values. y_data: Y values. + x_err: Optional error for X values. + y_err: Optional error for Y values. name: Name of this series. + legend_entry: Whether the drawn area must have a legend entry. Defaults to False. + legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. options: Valid options for the drawer backend API. """ @abstractmethod - def draw_formatted_data( + def draw_line( self, x_data: Sequence[float], y_data: Sequence[float], - y_err_data: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): - """Draw the formatted data that is used for fitting. + """Draw fit line. Args: x_data: X values. - y_data: Y values. - y_err_data: Standard deviation of Y values. + y_data: Fit Y values. name: Name of this series. + legend_entry: Whether the drawn area must have a legend entry. Defaults to False. + legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. options: Valid options for the drawer backend API. """ @abstractmethod - def draw_line( + def draw_filled_y_area( self, x_data: Sequence[float], - y_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): - """Draw fit line. + """Draw filled area as a function of x-values. Args: x_data: X values. - y_data: Fit Y values. + y_ub: The upper boundary of Y values. + y_lb: The lower boundary of Y values. name: Name of this series. + legend_entry: Whether the drawn area must have a legend entry. Defaults to False. + legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. options: Valid options for the drawer backend API. """ @abstractmethod - def draw_confidence_interval( + def draw_filled_x_area( self, - x_data: Sequence[float], - y_ub: Sequence[float], - y_lb: Sequence[float], + x_ub: Sequence[float], + x_lb: Sequence[float], + y_data: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): - """Draw confidence interval. + """Draw filled area as a function of y-values. Args: - x_data: X values. - y_ub: The upper boundary of Y values. - y_lb: The lower boundary of Y values. + x_ub: The upper boundary of X values. + x_lb: The lower boundary of X values. + y_data: Y values. name: Name of this series. + legend_entry: Whether the drawn area must have a legend entry. Defaults to False. + legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. options: Valid options for the drawer backend API. """ @abstractmethod - def draw_report( + def draw_text_box( self, description: str, + rel_pos: Optional[Tuple[float, float]] = None, **options, ): - """Draw text box that shows reports, such as fit results. + """Draw text box. Args: description: A string to be drawn inside a report box. + rel_pos: Relative position of the text-box. If None, the default ``text_box_rel_pos`` from + the style is used. options: Valid options for the drawer backend API. """ diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 0347f9c379..15c0801df9 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -12,7 +12,7 @@ """Curve drawer for matplotlib backend.""" -from typing import Optional, Sequence, Tuple +from typing import Dict, Optional, Sequence, Tuple import numpy as np from matplotlib.axes import Axes @@ -272,60 +272,107 @@ def _get_default_marker(self, name: str) -> str: ind = self._series.index(name) % len(self.DefaultMarkers) return self.DefaultMarkers[ind] - def draw_raw_data( + def _update_label_in_dict( + self, + options: Dict[str, any], + name: Optional[str], + legend_entry: bool, + legend_label: Optional[str], + ): + """Helper function to set the label entry in ``options`` based on given arguments. + + Args: + options: The options dictionary being modified. + name: A fall-back label if ``legend_label`` is None. If None, a blank string is used. + legend_entry: Whether to set "label" in ``options``. + legend_label: Optional label. If None, ``name`` is used. + """ + if legend_entry: + if legend_label is not None: + label = legend_label + elif name is not None: + label = name + else: + label = "" + options["label"] = label + + def draw_scatter( self, x_data: Sequence[float], y_data: Sequence[float], + x_err: Optional[Sequence[float]] = None, + y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): + series_params = self.plot_options.series_params.get(name, {}) marker = series_params.get("symbol", self._get_default_marker(name)) + color = series_params.get("color", self._get_default_color(name)) axis = series_params.get("canvas", None) draw_options = { - "color": "grey", + "color": color, "marker": marker, "alpha": 0.8, "zorder": 2, } + self._update_label_in_dict(draw_options, name, legend_entry, legend_label) draw_options.update(**options) - self._get_axis(axis).scatter(x_data, y_data, **draw_options) - def draw_formatted_data( + if x_err is None and y_err is None: + self._get_axis(axis).scatter(x_data, y_data, **draw_options) + else: + # Check for invalid error values. + if y_err is not None and not np.all(np.isfinite(y_err)): + y_err = None + if x_err is not None and not np.all(np.isfinite(x_err)): + x_err = None + + # `errorbar` has extra default draw_options to set, but we want to accept any overrides from + # `options`, and thus draw_options. + errorbar_options = { + "linestyle": "", + "markersize": 9, + } + errorbar_options.update(draw_options) + + self._get_axis(axis).errorbar( + x_data, y_data, yerr=y_err, xerr=x_err, **errorbar_options + ) + + def draw_line( self, x_data: Sequence[float], y_data: Sequence[float], - y_err_data: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): series_params = self.plot_options.series_params.get(name, {}) axis = series_params.get("canvas", None) color = series_params.get("color", self._get_default_color(name)) - marker = series_params.get("symbol", self._get_default_marker(name)) draw_ops = { "color": color, - "marker": marker, - "markersize": 9, - "alpha": 0.8, - "zorder": 4, - "linestyle": "", + "linestyle": "-", + "linewidth": 2, } + self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) draw_ops.update(**options) - if name: - draw_ops["label"] = name - - if not np.all(np.isfinite(y_err_data)): - y_err_data = None - self._get_axis(axis).errorbar(x_data, y_data, yerr=y_err_data, **draw_ops) + self._get_axis(axis).plot(x_data, y_data, **draw_ops) - def draw_line( + def draw_filled_y_area( self, x_data: Sequence[float], - y_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): series_params = self.plot_options.series_params.get(name, {}) @@ -333,20 +380,21 @@ def draw_line( color = series_params.get("color", self._get_default_color(name)) draw_ops = { + "alpha": 0.1, "color": color, - "zorder": 5, - "linestyle": "-", - "linewidth": 2, } + self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) draw_ops.update(**options) - self._get_axis(axis).plot(x_data, y_data, **draw_ops) + self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops) - def draw_confidence_interval( + def draw_filled_x_area( self, - x_data: Sequence[float], - y_ub: Sequence[float], - y_lb: Sequence[float], + x_ub: Sequence[float], + x_lb: Sequence[float], + y_data: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): series_params = self.plot_options.series_params.get(name, {}) @@ -354,16 +402,17 @@ def draw_confidence_interval( color = series_params.get("color", self._get_default_color(name)) draw_ops = { - "zorder": 3, "alpha": 0.1, "color": color, } + self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) draw_ops.update(**options) - self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops) + self._get_axis(axis).fill_between_x(y_data, x1=x_lb, x2=x_ub, **draw_ops) - def draw_report( + def draw_text_box( self, description: str, + rel_pos: Optional[Tuple[float, float]] = None, **options, ): bbox_props = { @@ -375,16 +424,19 @@ def draw_report( } bbox_props.update(**options) - report_handler = self._axis.text( - *self.style.report_rpos, + if rel_pos is None: + rel_pos = self.style.text_box_rel_pos + + text_box_handler = self._axis.text( + *rel_pos, s=description, ha="center", va="top", - size=self.style.report_text_size, + size=self.style.text_box_text_size, transform=self._axis.transAxes, - zorder=6, + zorder=1000, # Very large zorder to draw over other graphics. ) - report_handler.set_bbox(bbox_props) + text_box_handler.set_bbox(bbox_props) @property def figure(self) -> Figure: diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index f0e91c479f..46bf737064 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -60,8 +60,8 @@ def expected_figure_data_keys(cls) -> List[str]: Data Keys: report_text: A string containing any fit report information to be drawn in a box. - The style and position of the report is controlled by ``report_rpos`` and - ``report_text_size`` style parameters in :class:`PlotStyle`. + The style and position of the report is controlled by ``text_box_rel_pos`` and + ``text_box_text_size`` style parameters in :class:`PlotStyle`. """ return [ "report_text", @@ -86,34 +86,52 @@ def _default_options(cls) -> Options: def _plot_figure(self): """Plots a curve-fit figure.""" for ser in self.series: - # Scatter plot - if self.data_exists_for(ser, ["x", "y"]): - x, y = self.data_for(ser, ["x", "y"]) - self.drawer.draw_raw_data(x, y, ser) - # Scatter plot with error-bars + plotted_formatted_data = False if self.data_exists_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]): x, y, yerr = self.data_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]) - self.drawer.draw_formatted_data(x, y, yerr, ser) + self.drawer.draw_scatter(x, y, y_err=yerr, name=ser, zorder=2, legend_entry=True) + plotted_formatted_data = True + + # Scatter plot + if self.data_exists_for(ser, ["x", "y"]): + x, y = self.data_for(ser, ["x", "y"]) + options = { + "zorder": 1, + } + # If we plotted formatted data, differentiate scatter points by setting normal X-Y + # markers to gray. + if plotted_formatted_data: + options["color"] = "gray" + # If we didn't plot formatted data, the X-Y markers should be used for the legend. + if not plotted_formatted_data: + options["legend_entry"] = True + self.drawer.draw_scatter( + x, + y, + name=ser, + **options, + ) # Line plot for fit if self.data_exists_for(ser, ["x_interp", "y_mean"]): x, y = self.data_for(ser, ["x_interp", "y_mean"]) - self.drawer.draw_line(x, y, ser) + self.drawer.draw_line(x, y, name=ser, zorder=3) # Confidence interval plot if self.data_exists_for(ser, ["x_interp", "y_mean", "sigmas"]): x, y_mean, sigmas = self.data_for(ser, ["x_interp", "y_mean", "sigmas"]) for n_sigma, alpha in self.options.plot_sigma: - self.drawer.draw_confidence_interval( + self.drawer.draw_filled_y_area( x, y_mean + n_sigma * sigmas, y_mean - n_sigma * sigmas, - ser, + name=ser, alpha=alpha, + zorder=5, ) # Fit report if "report_text" in self.figure_data: report_text = self.figure_data["report_text"] - self.drawer.draw_report(report_text) + self.drawer.draw_text_box(report_text) diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index 907520432b..535252dcb0 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -50,11 +50,11 @@ def default_style(cls) -> "PlotStyle": # size of axis label new.axis_label_size: int = 16 - # relative position of fit report - new.report_rpos: Tuple[float, float] = (0.6, 0.95) + # relative position of a text + new.text_box_rel_pos: Tuple[float, float] = (0.6, 0.95) # size of fit report text - new.report_text_size: int = 14 + new.text_box_text_size: int = 14 return new diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py index accb7144f1..2fa89230d7 100644 --- a/test/visualization/mock_drawer.py +++ b/test/visualization/mock_drawer.py @@ -13,7 +13,7 @@ Mock drawer for testing. """ -from typing import Optional, Sequence +from typing import Optional, Sequence, Tuple from qiskit_experiments.visualization import BaseDrawer, PlotStyle @@ -48,51 +48,62 @@ def format_canvas(self): """Does nothing.""" pass - def draw_raw_data( + def draw_scatter( self, x_data: Sequence[float], y_data: Sequence[float], + x_err: Optional[Sequence[float]] = None, + y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): """Does nothing.""" pass - def draw_formatted_data( + def draw_line( self, x_data: Sequence[float], y_data: Sequence[float], - y_err_data: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): """Does nothing.""" pass - def draw_line( + def draw_filled_y_area( self, x_data: Sequence[float], - y_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): """Does nothing.""" pass - def draw_confidence_interval( + def draw_filled_x_area( self, - x_data: Sequence[float], - y_ub: Sequence[float], - y_lb: Sequence[float], + x_ub: Sequence[float], + x_lb: Sequence[float], + y_data: Sequence[float], name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, **options, ): """Does nothing.""" pass - def draw_report( + def draw_text_box( self, description: str, + rel_pos: Optional[Tuple[float, float]] = None, **options, ): """Does nothing.""" diff --git a/test/visualization/test_mpldrawer.py b/test/visualization/test_mpldrawer.py index f5042aca8d..4e0a3f5cc6 100644 --- a/test/visualization/test_mpldrawer.py +++ b/test/visualization/test_mpldrawer.py @@ -16,6 +16,7 @@ from test.base import QiskitExperimentsTestCase import matplotlib + from qiskit_experiments.visualization import MplDrawer @@ -28,11 +29,12 @@ def test_end_to_end(self): # Draw dummy data drawer.initialize_canvas() - drawer.draw_raw_data([0, 1, 2], [0, 1, 2], "seriesA") - drawer.draw_formatted_data([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], "seriesA") + drawer.draw_scatter([0, 1, 2], [0, 1, 2], "seriesA") + drawer.draw_scatter([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], None, "seriesA") drawer.draw_line([3, 2, 1], [1, 2, 3], "seriesB") - drawer.draw_confidence_interval([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], "seriesB") - drawer.draw_report(r"Dummy report text with LaTex $\beta$") + drawer.draw_filled_x_area([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], "seriesB") + drawer.draw_filled_y_area([-1, 0, 1, 2], [-1, -2, -1, -2], [1, 2, 1, 2], "seriesB") + drawer.draw_text_box(r"Dummy report text with LaTex $\beta$") # Get result fig = drawer.figure diff --git a/test/visualization/test_plotter_mpldrawer.py b/test/visualization/test_plotter_mpldrawer.py index 2301a0119a..5c76298233 100644 --- a/test/visualization/test_plotter_mpldrawer.py +++ b/test/visualization/test_plotter_mpldrawer.py @@ -16,6 +16,7 @@ from test.base import QiskitExperimentsTestCase import matplotlib + from qiskit_experiments.visualization import MplDrawer from .mock_plotter import MockPlotter From 9f4e1ee567f8c8127f39fb9c1d61a877e1acdc85 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 17:13:40 +0200 Subject: [PATCH 21/45] Fix and expand tests Add checks for invalid options in BasePlotter and BaseDrawer. Add check for invalid plot_options in BaseDrawer. --- .../visualization/drawers/base_drawer.py | 11 +++++ .../visualization/drawers/mpl_drawer.py | 2 +- .../visualization/plotters/base_plotter.py | 10 ++++ qiskit_experiments/visualization/style.py | 5 ++ test/visualization/test_mpldrawer.py | 10 ++-- test/visualization/test_style.py | 49 ++++++++++++++++++- 6 files changed, 79 insertions(+), 8 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 3052c3b4ea..198b194be0 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -179,6 +179,12 @@ def set_options(self, **fields): Args: fields: The fields to update the options """ + for field in fields: + if not hasattr(self._options, field): + raise AttributeError( + f"Options field {field} is not valid for {type(self).__name__}" + ) + self._options.update_options(**fields) self._set_options = self._set_options.union(fields) @@ -187,6 +193,11 @@ def set_plot_options(self, **fields): Args: fields: The fields to update the plot options """ + for field in fields: + if not hasattr(self._plot_options, field): + raise AttributeError( + f"Plot options field {field} is not valid for {type(self).__name__}" + ) self._plot_options.update_options(**fields) self._set_plot_options = self._set_plot_options.union(fields) diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 15c0801df9..4ebfae5e8b 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -407,7 +407,7 @@ def draw_filled_x_area( } self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) draw_ops.update(**options) - self._get_axis(axis).fill_between_x(y_data, x1=x_lb, x2=x_ub, **draw_ops) + self._get_axis(axis).fill_betweenx(y_data, x1=x_lb, x2=x_ub, **draw_ops) def draw_text_box( self, diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 0bec63d5e4..a80be477ba 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -370,6 +370,11 @@ def set_options(self, **fields): Args: fields: The fields to update in options. """ + for field in fields: + if not hasattr(self._options, field): + raise AttributeError( + f"Options field {field} is not valid for {type(self).__name__}" + ) self._options.update_options(**fields) self._set_options = self._set_options.union(fields) @@ -379,6 +384,11 @@ def set_plot_options(self, **fields): Args: fields: The fields to update in plot options. """ + # Don't check if any option in fields already exists (like with `set_options`), as plot options + # are passed to `.drawer` which may have other plot-options. Any plot-option that isn't set in + # `.drawer.plot_options` won't be set anyway. Setting `.drawer.plot_options` only occurs in + # `.figure()`, so we can't compare to `.drawer.plot_options` now as `.drawer` may be changed + # between now and the call to `.figure()`. self._plot_options.update_options(**fields) self._set_plot_options = self._set_plot_options.union(fields) diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index 535252dcb0..172478e9b6 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -106,6 +106,11 @@ def config(self) -> Dict: **self._fields, } + @property + def __dict__(self) -> Dict: + # Needed as __dict__ is not inherited by subclasses. + return super().__dict__ + def __json_encode__(self): return self.config() diff --git a/test/visualization/test_mpldrawer.py b/test/visualization/test_mpldrawer.py index 4e0a3f5cc6..c4c2230631 100644 --- a/test/visualization/test_mpldrawer.py +++ b/test/visualization/test_mpldrawer.py @@ -29,11 +29,11 @@ def test_end_to_end(self): # Draw dummy data drawer.initialize_canvas() - drawer.draw_scatter([0, 1, 2], [0, 1, 2], "seriesA") - drawer.draw_scatter([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], None, "seriesA") - drawer.draw_line([3, 2, 1], [1, 2, 3], "seriesB") - drawer.draw_filled_x_area([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], "seriesB") - drawer.draw_filled_y_area([-1, 0, 1, 2], [-1, -2, -1, -2], [1, 2, 1, 2], "seriesB") + drawer.draw_scatter([0, 1, 2], [0, 1, 2], name="seriesA") + drawer.draw_scatter([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], None, name="seriesA") + drawer.draw_line([3, 2, 1], [1, 2, 3], name="seriesB") + drawer.draw_filled_x_area([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], name="seriesB") + drawer.draw_filled_y_area([-1, 0, 1, 2], [-1, -2, -1, -2], [1, 2, 1, 2], name="seriesB") drawer.draw_text_box(r"Dummy report text with LaTex $\beta$") # Get result diff --git a/test/visualization/test_style.py b/test/visualization/test_style.py index 3f1f01cb27..1354673529 100644 --- a/test/visualization/test_style.py +++ b/test/visualization/test_style.py @@ -57,8 +57,8 @@ def test_default_contains_necessary_fields(self): "legend_loc", "tick_label_size", "axis_label_size", - "report_rpos", - "report_text_size", + "text_box_rel_pos", + "text_box_text_size", ] for field in expected_not_none_fields: self.assertIsNotNone(getattr(default, field)) @@ -97,3 +97,48 @@ def test_field_access(self): # Disable pointless-statement as accessing style fields can raise an exception. # pylint: disable = pointless-statement dummy_style.y + + def test_dict(self): + """Test that PlotStyle can be converted into a dictionary.""" + dummy_style = PlotStyle( + a="a", + b=0, + c=[1, 2, 3], + ) + expected_dict = { + "a": "a", + "b": 0, + "c": [1, 2, 3], + } + actual_dict = dummy_style.__dict__ + self.assertDictEqual(actual_dict, expected_dict, msg="PlotStyle dict is not as expected.") + + # Add a new variable + dummy_style.new_variable = 5e9 + expected_dict["new_variable"] = 5e9 + actual_dict = dummy_style.__dict__ + self.assertDictEqual( + actual_dict, + expected_dict, + msg="PlotStyle dict is not as expected, with post-init variables.", + ) + + def test_update_dict(self): + """Test that PlotStyle dictionary is correct when updated.""" + custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() + + # copy(...) is needed as .update() modifies the style instance + actual_12 = copy(custom_1) + actual_12.update(custom_2) + actual_21 = copy(custom_2) + actual_21.update(custom_1) + + self.assertDictEqual(actual_12.__dict__, expected_12.__dict__) + self.assertDictEqual(actual_21.__dict__, expected_21.__dict__) + + def test_merge(self): + """Test that PlotStyle dictionary is correct when merged.""" + custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() + + self.assertDictEqual(PlotStyle.merge(custom_1, custom_2).__dict__, expected_12.__dict__) + self.assertDictEqual(PlotStyle.merge(custom_2, custom_1).__dict__, expected_21.__dict__) From 956696a10a3e77066acec397086f211e6b6cebe0 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 21:18:10 +0200 Subject: [PATCH 22/45] Rename plot-options to figure-options --- .../curve_analysis/curve_analysis.py | 2 +- .../standard_analysis/bloch_trajectory.py | 2 +- .../error_amplification_analysis.py | 2 +- .../standard_analysis/gaussian.py | 2 +- .../standard_analysis/resonance.py | 2 +- .../analysis/cr_hamiltonian_analysis.py | 2 +- .../analysis/drag_analysis.py | 2 +- .../analysis/ramsey_xy_analysis.py | 2 +- .../characterization/analysis/t1_analysis.py | 4 +- .../analysis/t2hahn_analysis.py | 2 +- .../analysis/t2ramsey_analysis.py | 2 +- .../library/characterization/rabi.py | 2 +- .../randomized_benchmarking/rb_analysis.py | 2 +- .../visualization/drawers/base_drawer.py | 56 +++++----- .../visualization/drawers/mpl_drawer.py | 32 +++--- .../visualization/plotters/base_plotter.py | 104 +++++++++--------- .../visualization/plotters/curve_plotter.py | 2 +- 17 files changed, 110 insertions(+), 112 deletions(-) diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index ab21129574..d5868dd511 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -154,7 +154,7 @@ def __init__( "symbol": series_def.plot_symbol, "canvas": series_def.canvas, } - self.plotter.set_plot_options(series_params=series_params) + self.plotter.set_figure_options(series_params=series_params) self._models = models or [] self._name = name or self.__class__.__name__ diff --git a/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py b/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py index c250c86b9c..f3c8909a25 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/bloch_trajectory.py @@ -138,7 +138,7 @@ def _default_options(cls): input_key="counts", data_actions=[dp.Probability("1"), dp.BasisExpectationValue()], ) - default_options.plotter.set_plot_options( + default_options.plotter.set_figure_options( xlabel="Flat top width", ylabel="Pauli expectation values", xval_unit="s", diff --git a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py index dc59b4864f..116430f2d9 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/error_amplification_analysis.py @@ -105,7 +105,7 @@ def _default_options(cls): considered as good. Defaults to :math:`\pi/2`. """ default_options = super()._default_options() - default_options.plotter.set_plot_options( + default_options.plotter.set_figure_options( xlabel="Number of gates (n)", ylabel="Population", ylim=(0, 1.0), diff --git a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py index 370d318f7f..2a17f54ac0 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/gaussian.py @@ -76,7 +76,7 @@ def __init__( @classmethod def _default_options(cls) -> Options: options = super()._default_options() - options.plotter.set_plot_options( + options.plotter.set_figure_options( xlabel="Frequency", ylabel="Signal (arb. units)", xval_unit="Hz", diff --git a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py index a902643f4a..558de514d8 100644 --- a/qiskit_experiments/curve_analysis/standard_analysis/resonance.py +++ b/qiskit_experiments/curve_analysis/standard_analysis/resonance.py @@ -76,7 +76,7 @@ def __init__( @classmethod def _default_options(cls) -> Options: options = super()._default_options() - options.plotter.set_plot_options( + options.plotter.set_figure_options( xlabel="Frequency", ylabel="Signal (arb. units)", xval_unit="Hz", diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index 5a35dbf98a..3de281fb06 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -69,7 +69,7 @@ def _default_options(cls): text_box_rel_pos=(0.28, -0.10), ), ) - default_options.plotter.set_plot_options( + default_options.plotter.set_figure_options( xlabel="Flat top width", ylabel=[ r"$\langle$X(t)$\rangle$", diff --git a/qiskit_experiments/library/characterization/analysis/drag_analysis.py b/qiskit_experiments/library/characterization/analysis/drag_analysis.py index 00be74548f..1ca7c6a168 100644 --- a/qiskit_experiments/library/characterization/analysis/drag_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/drag_analysis.py @@ -84,7 +84,7 @@ def _default_options(cls): descriptions of analysis options. """ default_options = super()._default_options() - default_options.plotter.set_plot_options( + default_options.plotter.set_figure_options( xlabel="Beta", ylabel="Signal (arb. units)", ) diff --git a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py index bdd89d9eda..22e093a30b 100644 --- a/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/ramsey_xy_analysis.py @@ -88,7 +88,7 @@ def _default_options(cls): descriptions of analysis options. """ default_options = super()._default_options() - default_options.plotter.set_plot_options( + default_options.plotter.set_figure_options( xlabel="Delay", ylabel="Signal (arb. units)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t1_analysis.py b/qiskit_experiments/library/characterization/analysis/t1_analysis.py index c00a42e8b5..91a69cc2a5 100644 --- a/qiskit_experiments/library/characterization/analysis/t1_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t1_analysis.py @@ -34,7 +34,7 @@ class T1Analysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.set_plot_options( + options.plotter.set_figure_options( xlabel="Delay", ylabel="P(1)", xval_unit="s", @@ -85,7 +85,7 @@ class T1KerneledAnalysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.set_plot_options( + options.plotter.set_figure_options( xlabel="Delay", ylabel="Normalized Projection on the Main Axis", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py b/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py index 532ff4a706..fa1f3c958e 100644 --- a/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t2hahn_analysis.py @@ -34,7 +34,7 @@ class T2HahnAnalysis(curve.DecayAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.set_plot_options( + options.plotter.set_figure_options( xlabel="Delay", ylabel="P(0)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py b/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py index 0d20592221..d33d60a478 100644 --- a/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/t2ramsey_analysis.py @@ -29,7 +29,7 @@ class T2RamseyAnalysis(curve.DampedOscillationAnalysis): def _default_options(cls) -> Options: """Default analysis options.""" options = super()._default_options() - options.plotter.set_plot_options( + options.plotter.set_figure_options( xlabel="Delay", ylabel="P(1)", xval_unit="s", diff --git a/qiskit_experiments/library/characterization/rabi.py b/qiskit_experiments/library/characterization/rabi.py index 6bd3d1af12..5d880fe755 100644 --- a/qiskit_experiments/library/characterization/rabi.py +++ b/qiskit_experiments/library/characterization/rabi.py @@ -110,7 +110,7 @@ def __init__( result_parameters=[ParameterRepr("freq", self.__outcome__)], normalization=True, ) - self.analysis.plotter.set_plot_options( + self.analysis.plotter.set_figure_options( xlabel="Amplitude", ylabel="Signal (arb. units)", ) diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py index 5cfb22ae34..2bc67c8bd5 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_analysis.py @@ -96,7 +96,7 @@ def _default_options(cls): 2Q RB is corrected to exclude the depolarization of underlying 1Q channels. """ default_options = super()._default_options() - default_options.plotter.set_plot_options( + default_options.plotter.set_figure_options( xlabel="Clifford Length", ylabel="P(0)", ) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 198b194be0..a9e83f09a8 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -82,11 +82,11 @@ def __init__(self): # A set of changed options for serialization. self._set_options = set() - # Plot options which are typically updated by a plotter instance. Plot-options include the axis - # labels, figure title, and a custom style instance. - self._plot_options = self._default_plot_options() - # A set of changed plot-options for serialization. - self._set_plot_options = set() + # Figure options which are typically updated by a plotter instance. Figure-options include the + # axis labels, figure title, and a custom style instance. + self._figure_options = self._default_figure_options() + # A set of changed figure-options for serialization. + self._set_figure_options = set() # The initialized axis/axes, set by `initialize_canvas`. self._axis = None @@ -97,14 +97,14 @@ def options(self) -> Options: return self._options @property - def plot_options(self) -> Options: - """Return the plot options. + def figure_options(self) -> Options: + """Return the figure options. These are typically updated by a plotter instance, and thus may change. It is recommended to set - plot options in a parent :class:`BasePlotter` instance that contains the :class:`BaseDrawer` + figure options in a parent :class:`BasePlotter` instance that contains the :class:`BaseDrawer` instance. """ - return self._plot_options + return self._figure_options @classmethod def _default_options(cls) -> Options: @@ -130,10 +130,10 @@ def _default_style(cls) -> PlotStyle: return PlotStyle.default_style() @classmethod - def _default_plot_options(cls) -> Options: - """Return default plot options. + def _default_figure_options(cls) -> Options: + """Return default figure options. - Plot Options: + Figure Options: xlabel (Union[str, List[str]]): X-axis label string of the output figure. If there are multiple columns in the canvas, this could be a list of labels. ylabel (Union[str, List[str]]): Y-axis label string of the output figure. @@ -153,7 +153,7 @@ def _default_plot_options(cls) -> Options: displayed in the scientific notation. yval_unit (str): Unit of y values. See ``xval_unit`` for details. figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. - series_params (Dict[str, Dict[str, Any]]): A dictionary of plot parameters for each series. + series_params (Dict[str, Dict[str, Any]]): A dictionary of parameters for each series. This is keyed on the name for each series. Sub-dictionary is expected to have following three configurations, "canvas", "color", and "symbol"; "canvas" is the integer index of axis (when multi-canvas plot is set), "color" is the color of the series, and "symbol" is @@ -188,33 +188,33 @@ def set_options(self, **fields): self._options.update_options(**fields) self._set_options = self._set_options.union(fields) - def set_plot_options(self, **fields): - """Set the plot options. + def set_figure_options(self, **fields): + """Set the figure options. Args: - fields: The fields to update the plot options + fields: The fields to update the figure options """ for field in fields: - if not hasattr(self._plot_options, field): + if not hasattr(self._figure_options, field): raise AttributeError( - f"Plot options field {field} is not valid for {type(self).__name__}" + f"Figure options field {field} is not valid for {type(self).__name__}" ) - self._plot_options.update_options(**fields) - self._set_plot_options = self._set_plot_options.union(fields) + self._figure_options.update_options(**fields) + self._set_figure_options = self._set_figure_options.union(fields) @property def style(self) -> PlotStyle: """The combined plot style for this drawer. The returned style instance is a combination of :attr:`options.default_style` and - :attr:`plot_options.custom_style`. Style parameters set in ``custom_style`` override those set in + :attr:`figure_options.custom_style`. Style parameters set in ``custom_style`` override those set in ``default_style``. If ``custom_style`` is not an instance of :class:`PlotStyle`, the returned style is equivalent to ``default_style``. Returns: PlotStyle: The plot style for this drawer. """ - if isinstance(self.plot_options.custom_style, PlotStyle): - return PlotStyle.merge(self.options.default_style, self.plot_options.custom_style) + if isinstance(self.figure_options.custom_style, PlotStyle): + return PlotStyle.merge(self.options.default_style, self.figure_options.custom_style) return self.options.default_style @abstractmethod @@ -341,14 +341,14 @@ def figure(self): def config(self) -> Dict: """Return the config dictionary for this drawer.""" options = dict((key, getattr(self._options, key)) for key in self._set_options) - plot_options = dict( - (key, getattr(self._plot_options, key)) for key in self._set_plot_options + figure_options = dict( + (key, getattr(self._figure_options, key)) for key in self._set_figure_options ) return { "cls": type(self), "options": options, - "plot_options": plot_options, + "figure_options": figure_options, } def __json_encode__(self): @@ -359,6 +359,6 @@ def __json_decode__(cls, value): instance = cls() if "options" in value: instance.set_options(**value["options"]) - if "plot_options" in value: - instance.set_plot_options(**value["plot_options"]) + if "figure_options" in value: + instance.set_figure_options(**value["figure_options"]) return instance diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 4ebfae5e8b..97dd50fd06 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -87,8 +87,8 @@ def initialize_canvas(self): sub_ax.set_yticklabels([]) else: # this axis locates at left, write y-label - if self.plot_options.ylabel: - label = self.plot_options.ylabel + if self.figure_options.ylabel: + label = self.figure_options.ylabel if isinstance(label, list): # Y label can be given as a list for each sub axis label = label[i] @@ -98,8 +98,8 @@ def initialize_canvas(self): sub_ax.set_xticklabels([]) else: # this axis locates at bottom, write x-label - if self.plot_options.xlabel: - label = self.plot_options.xlabel + if self.figure_options.xlabel: + label = self.figure_options.xlabel if isinstance(label, list): # X label can be given as a list for each sub axis label = label[j] @@ -112,8 +112,8 @@ def initialize_canvas(self): # Remove original axis frames axis.axis("off") else: - axis.set_xlabel(self.plot_options.xlabel, fontsize=self.style.axis_label_size) - axis.set_ylabel(self.plot_options.ylabel, fontsize=self.style.axis_label_size) + axis.set_xlabel(self.figure_options.xlabel, fontsize=self.style.axis_label_size) + axis.set_ylabel(self.figure_options.ylabel, fontsize=self.style.axis_label_size) axis.tick_params(labelsize=self.style.tick_label_size) axis.grid() @@ -136,11 +136,11 @@ def format_canvas(self): for ax_type in ("x", "y"): # Get axis formatter from drawing options if ax_type == "x": - lim = self.plot_options.xlim - unit = self.plot_options.xval_unit + lim = self.figure_options.xlim + unit = self.figure_options.xval_unit else: - lim = self.plot_options.ylim - unit = self.plot_options.yval_unit + lim = self.figure_options.ylim + unit = self.figure_options.yval_unit # Compute data range from auto scale if not lim: @@ -213,9 +213,9 @@ def format_canvas(self): ax1.sharey(ax2) all_axes[0].set_ylim(lim) # Add title - if self.plot_options.figure_title is not None: + if self.figure_options.figure_title is not None: self._axis.set_title( - label=self.plot_options.figure_title, + label=self.figure_options.figure_title, fontsize=self.style.axis_label_size, ) @@ -308,7 +308,7 @@ def draw_scatter( **options, ): - series_params = self.plot_options.series_params.get(name, {}) + series_params = self.figure_options.series_params.get(name, {}) marker = series_params.get("symbol", self._get_default_marker(name)) color = series_params.get("color", self._get_default_color(name)) axis = series_params.get("canvas", None) @@ -352,7 +352,7 @@ def draw_line( legend_label: Optional[str] = None, **options, ): - series_params = self.plot_options.series_params.get(name, {}) + series_params = self.figure_options.series_params.get(name, {}) axis = series_params.get("canvas", None) color = series_params.get("color", self._get_default_color(name)) @@ -375,7 +375,7 @@ def draw_filled_y_area( legend_label: Optional[str] = None, **options, ): - series_params = self.plot_options.series_params.get(name, {}) + series_params = self.figure_options.series_params.get(name, {}) axis = series_params.get("canvas", None) color = series_params.get("color", self._get_default_color(name)) @@ -397,7 +397,7 @@ def draw_filled_x_area( legend_label: Optional[str] = None, **options, ): - series_params = self.plot_options.series_params.get(name, {}) + series_params = self.figure_options.series_params.get(name, {}) axis = series_params.get("canvas", None) color = series_params.get("color", self._get_default_color(name)) diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index a80be477ba..ea90a56135 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -41,7 +41,7 @@ class BasePlotter(ABC): instead only associated with the figure. Examples include analysis reports or other text that is drawn onto the figure canvas. - # TODO: Add example usage and description of options and plot-options. + # TODO: Add example usage and description of options and figure-options. """ def __init__(self, drawer: BaseDrawer): @@ -60,10 +60,10 @@ def __init__(self, drawer: BaseDrawer): # Plotter options that have changed, for serialization. self._set_options = set() - # Plot options that are updated in the drawer when `plotter.figure()` is called - self._plot_options = self._default_plot_options() - # Plot options that have changed, for serialization. - self._set_plot_options = set() + # Figure options that are updated in the drawer when `plotter.figure()` is called + self._figure_options = self._default_figure_options() + # Figure options that have changed, for serialization. + self._set_figure_options = set() # The drawer backend to use for plotting. self._drawer = drawer @@ -261,7 +261,7 @@ def figure(self) -> Any: Returns: Any: A figure generated by :attr:`drawer`, of the same type as ``drawer.figure``. """ - # Initialize drawer, to copy axis, subplots, and plot-options across. + # Initialize drawer, to copy axis, subplots, style, and figure-options across. self._initialize_drawer() # Initialize canvas, which creates subplots, assigns axis labels, etc. @@ -292,19 +292,19 @@ def options(self) -> Options: Options for a plotter modify how the class generates a figure. This includes an optional axis object, being the drawer canvas. Make sure verify whether the option you want to set is in - :attr:`options` or :attr:`plot_options`. + :attr:`options` or :attr:`figure_options`. """ return self._options @property - def plot_options(self) -> Options: - """Plot options for the plotter and its drawer. + def figure_options(self) -> Options: + """Figure options for the plotter and its drawer. - Plot options differ from normal options (:attr:`options`) in that the plotter passes plot options - on to the drawer when creating a figure (when :meth:`figure` is called). This way :attr:`drawer` - can draw an appropriate figure. An example of a plot option is the x-axis label. + Figure options differ from normal options (:attr:`options`) in that the plotter passes figure + options on to the drawer when creating a figure (when :meth:`figure` is called). This way + :attr:`drawer` can draw an appropriate figure. An example of a figure option is the x-axis label. """ - return self._plot_options + return self._figure_options @classmethod def _default_options(cls) -> Options: @@ -315,8 +315,8 @@ def _default_options(cls) -> Options: subplots (Tuple[int, int]): Number of rows and columns when the experimental result is drawn in the multiple windows. style (PlotStyle): The style definition to use when plotting. - This overwrites plotting options set in :attr:`drawer`. The default is an empty style - object, and such the default drawer plotting style will be used. + This overwrites figure-option `custom_style` set in :attr:`drawer`. The default is an + empty style object, and such the default :attr:`drawer` plotting style will be used. """ return Options( axis=None, @@ -325,10 +325,10 @@ def _default_options(cls) -> Options: ) @classmethod - def _default_plot_options(cls) -> Options: - """Return default plot options. + def _default_figure_options(cls) -> Options: + """Return default figure options. - Plot Options: + Figure Options: xlabel (Union[str, List[str]]): X-axis label string of the output figure. If there are multiple columns in the canvas, this could be a list of labels. ylabel (Union[str, List[str]]): Y-axis label string of the output figure. @@ -378,68 +378,66 @@ def set_options(self, **fields): self._options.update_options(**fields) self._set_options = self._set_options.union(fields) - def set_plot_options(self, **fields): - """Set the plot options. + def set_figure_options(self, **fields): + """Set the figure options. Args: - fields: The fields to update in plot options. + fields: The fields to update in figure options. """ - # Don't check if any option in fields already exists (like with `set_options`), as plot options - # are passed to `.drawer` which may have other plot-options. Any plot-option that isn't set in - # `.drawer.plot_options` won't be set anyway. Setting `.drawer.plot_options` only occurs in - # `.figure()`, so we can't compare to `.drawer.plot_options` now as `.drawer` may be changed + # Don't check if any option in fields already exists (like with `set_options`), as figure options + # are passed to `.drawer` which may have other figure-options. Any figure-option that isn't set + # in `.drawer.figure_options` won't be set anyway. Setting `.drawer.figure_options` only occurs + # in `.figure()`, so we can't compare to `.drawer.figure_options` now as `.drawer` may be changed # between now and the call to `.figure()`. - self._plot_options.update_options(**fields) - self._set_plot_options = self._set_plot_options.union(fields) + self._figure_options.update_options(**fields) + self._set_figure_options = self._set_figure_options.union(fields) def _initialize_drawer(self): """Configures :attr:`drawer` before plotting. The following actions are taken: 1. ``axis``, ``subplots``, and ``style`` are passed to :attr:`drawer`. - 2. ``plot_options`` in :attr:`drawer` are updated based on values set in plotter - :attr:`plot_options` - - These steps are different as any plot-option can be passed to :attr:`drawer` if the drawer has a - plot-option with the same name. However, ``axis``, ``subplots``, and ``style`` are the only - plotter options passed to :attr:`drawer`. This is done because :class:`BasePlotter` distinguishes - between plotter options and plot-options. + 2. ``figure_options`` in :attr:`drawer` are updated based on values set in the plotter + :attr:`figure_options` + + These steps are different as all figure-options could be passed to :attr:`drawer`, if the drawer + already has a figure-option with the same name. ``axis``, ``subplots``, and ``style`` are the + only plotter options (from :attr:`options`) passed to :attr:`drawer` in + :meth:`_initialize_drawer`. This is done as these options make more sense as an option for a + plotter, given the interface of :class:`BasePlotter`. """ ## Axis, subplots, and style if self.options.axis: self.drawer.set_options(axis=self.options.axis) if self.options.subplots: self.drawer.set_options(subplots=self.options.subplots) - self.drawer.set_plot_options(custom_style=self.options.style) + self.drawer.set_figure_options(custom_style=self.options.style) - ## Plot Options - # HACK: We need to accesses internal variables of the Options class, which is not good practice. - # Options._fields are dictionaries. However, given we are accessing an internal variable, this - # may change in the future. - _drawer_plot_options = self.drawer.plot_options._fields - _plotter_plot_options = self.plot_options._fields + # Convert options to dictionaries for easy comparison of all options/fields. + _drawer_figure_options = self.drawer.figure_options.__dict__ + _plotter_figure_options = self.figure_options.__dict__ - # If an option exists in drawer.plot_options AND in self.plot_options, set the drawers - # plot-option value to that from the plotter. - for opt_key in _drawer_plot_options: - if opt_key in _plotter_plot_options: - _drawer_plot_options[opt_key] = _plotter_plot_options[opt_key] + # If an option exists in drawer.figure_options AND in self.figure_options, set the drawers + # figure-option value to that from the plotter. + for opt_key in _drawer_figure_options: + if opt_key in _plotter_figure_options: + _drawer_figure_options[opt_key] = _plotter_figure_options[opt_key] - # Use drawer.set_plot_options so plot-options are serialized. - self.drawer.set_plot_options(**_drawer_plot_options) + # Use drawer.set_figure_options so figure-options are serialized. + self.drawer.set_figure_options(**_drawer_figure_options) def config(self) -> Dict: """Return the config dictionary for this drawing.""" options = dict((key, getattr(self._options, key)) for key in self._set_options) - plot_options = dict( - (key, getattr(self._plot_options, key)) for key in self._set_plot_options + figure_options = dict( + (key, getattr(self._figure_options, key)) for key in self._set_figure_options ) drawer = self.drawer.__json_encode__() return { "cls": type(self), "options": options, - "plot_options": plot_options, + "figure_options": figure_options, "drawer": drawer, } @@ -458,6 +456,6 @@ def __json_decode__(cls, value): instance = cls(drawer) if "options" in value: instance.set_options(**value["options"]) - if "plot_options" in value: - instance.set_plot_options(**value["plot_options"]) + if "figure_options" in value: + instance.set_figure_options(**value["figure_options"]) return instance diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 46bf737064..4e74f6eab9 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -71,7 +71,7 @@ def expected_figure_data_keys(cls) -> List[str]: def _default_options(cls) -> Options: """Return curve-plotter specific default plotter options. - Plot Options: + Options: plot_sigma (List[Tuple[float, float]]): A list of two number tuples showing the configuration to write confidence intervals for the fit curve. The first argument is the relative sigma (n_sigma), and the second argument is From ac1a9840533b9059150e21b05d730bb127b55bee Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 21:19:52 +0200 Subject: [PATCH 23/45] Fix tests --- test/visualization/test_plotter_drawer.py | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/test/visualization/test_plotter_drawer.py b/test/visualization/test_plotter_drawer.py index 1bf4f4e4e5..1d7dd8c617 100644 --- a/test/visualization/test_plotter_drawer.py +++ b/test/visualization/test_plotter_drawer.py @@ -31,7 +31,7 @@ def dummy_plotter() -> BasePlotter: """ plotter = MockPlotter(MockDrawer()) # Set dummy plot options to update - plotter.set_plot_options( + plotter.set_figure_options( xlabel="xlabel", ylabel="ylabel", figure_title="figure_title", @@ -91,15 +91,15 @@ def assertOptionsEqual( f"{getattr(options1, key),} vs {getattr(options2,key)}", ) - def test_plot_options(self): + def test_figure_options(self): """Test copying and passing of plot-options between plotter and drawer.""" plotter = dummy_plotter() # Expected options - expected_plot_options = copy(plotter.drawer.plot_options) - expected_plot_options.xlabel = "xlabel" - expected_plot_options.ylabel = "ylabel" - expected_plot_options.figure_title = "figure_title" + expected_figure_options = copy(plotter.drawer.figure_options) + expected_figure_options.xlabel = "xlabel" + expected_figure_options.ylabel = "ylabel" + expected_figure_options.figure_title = "figure_title" # Expected style expected_custom_style = PlotStyle( @@ -109,9 +109,9 @@ def test_plot_options(self): expected_full_style = PlotStyle.merge( plotter.drawer.options.default_style, expected_custom_style ) - expected_plot_options.custom_style = expected_custom_style + expected_figure_options.custom_style = expected_custom_style - # Call plotter.figure() to force passing of plot_options to drawer + # Call plotter.figure() to force passing of figure_options to drawer plotter.figure() ## Test values @@ -120,14 +120,14 @@ def test_plot_options(self): # Check individual plot-options, but only the intersection as those are the ones we expect to be # updated. - self.assertOptionsEqual(expected_plot_options, plotter.drawer.plot_options, True) + self.assertOptionsEqual(expected_figure_options, plotter.drawer.figure_options, True) - # Coarse equality check of plot_options + # Coarse equality check of figure_options self.assertEqual( - expected_plot_options, - plotter.drawer.plot_options, - msg=rf"expected_plot_options = {expected_plot_options}\nactual_plot_options =" - rf"{plotter.drawer.plot_options}", + expected_figure_options, + plotter.drawer.figure_options, + msg=rf"expected_figure_options = {expected_figure_options}\nactual_figure_options =" + rf"{plotter.drawer.figure_options}", ) def test_serializable(self): @@ -137,10 +137,10 @@ def test_serializable(self): def check_options(original, new): """Verifies that ``new`` plotter has the same options as ``original`` plotter.""" self.assertOptionsEqual(original.options, new.options, "options") - self.assertOptionsEqual(original.plot_options, new.plot_options, "plot_options") + self.assertOptionsEqual(original.figure_options, new.figure_options, "figure_options") self.assertOptionsEqual(original.drawer.options, new.drawer.options, "drawer.options") self.assertOptionsEqual( - original.drawer.plot_options, new.drawer.plot_options, "drawer.plot_options" + original.drawer.figure_options, new.drawer.figure_options, "drawer.figure_options" ) ## Check that plotter, BEFORE PLOTTING, survives serialization correctly. From 77320654d0729b3e3ea763f22b4dbb381ef4b09b Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 21:39:52 +0200 Subject: [PATCH 24/45] Cleanup docstrings Co-authored-by: Daniel J. Egger <38065505+eggerdj@users.noreply.github.com> --- qiskit_experiments/visualization/__init__.py | 22 +++++++++++++++---- .../visualization/plotters/base_plotter.py | 12 +++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 610b1d8767..799dd6e451 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -18,6 +18,22 @@ Visualization provides plotting functionality for creating figures from experiment and analysis results. This includes plotter and drawer classes to plot data in :py:class:`CurveAnalysis` and its subclasses. +Plotters inherit from :class:`BasePlotter` and define a type of figure that may be generated from +experiment or analysis data. For example, the results from :class:`CurveAnalysis` --- or any other +experiment where results are plotted against a single parameter (i.e., :math:`x`) --- can be plotted +using the :class:`CurvePlotter` class, which plots X-Y-like values. + +These plotter classes act as a bridge (from the common bridge pattern in software development) between +analysis classes (or even users) and plotting backends such as Matplotlib. Drawers are the backends, with +a common interface defined in :class:`BaseDrawer`. Though Matplotlib is the only officially supported +plotting backend in Qiskit Experiments (i.e., through :class:`MplDrawer`), custom drawers can be +implemented by users to use alternative backends. As long as the backend is a subclass of +:class:`BaseDrawer`, and implements all the necessary functionality, all plotters should be able to +generate figures with the alternative backend. + +To collate style parameters together, plotters and drawers store instances of the :class:`PlotStyle` +class. These instances can be merged and updated, so that default styles can have their values +overwritten. Plotter Library ============== @@ -26,8 +42,7 @@ :toctree: ../stubs/ :template: autosummary/class.rst - BasePlotter - CurvePlotter + BasePlotter CurvePlotter Drawer Library ============== @@ -36,8 +51,7 @@ :toctree: ../stubs/ :template: autosummary/class.rst - BaseDrawer - MplDrawer + BaseDrawer MplDrawer Plotting Style ============== diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index ea90a56135..a37de03536 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -93,8 +93,9 @@ def series_data(self) -> Dict[str, Dict[str, Any]]: Series data includes data such as scatter points, interpolated fit values, and standard-deviations. Series data is grouped by series-name and then by a data-key, both strings. - Though series data can be accessed through :meth:`series_data`, it is recommended to use - :meth:`data_for` and :meth:`data_exists_for`. + Though series data can be accessed through :meth:`series_data`, it is recommended to access them + with :meth:`data_for` and :meth:`data_exists_for` as they allow for easier access to nested + values and can handle multiple data-keys in one query. Returns: dict: A dictionary containing series data. @@ -110,7 +111,8 @@ def data_keys_for(self, series_name: str) -> List[str]: """Returns a list of data-keys for the given series. Args: - series_name: The series name for the given series. + series_name: The series name for which to return the data-keys, i.e., the types of data for + each series. Returns: list: The list of data-keys for data in the plotter associated with the given series. If the @@ -133,8 +135,8 @@ def data_for(self, series_name: str, data_keys: Union[str, List[str]]) -> Tuple[ x, y, yerr = plotter.series_data_for("seriesA", ["x", "y", "yerr"]) x, y, yerr = data.x, data.y, data.yerr - :meth:`data_for` is intended to be used by sub-classes of :class:`BasePlotter` when - plotting in :meth:`_plot_figure`. + :meth:`data_for` is intended to be used by sub-classes of :class:`BasePlotter` when plotting in + the :meth:`_plot_figure` method. Args: series_name: The series name for the given series. From 8c588dc63363270beb062e2ad319321afce2f762 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 22:29:26 +0200 Subject: [PATCH 25/45] Add description of options and figure_options for plotters and drawers --- .../curve_analysis/base_curve_analysis.py | 2 +- .../visualization/drawers/base_drawer.py | 14 ++++ .../visualization/plotters/base_plotter.py | 65 ++++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index f568158c5e..bbc098300d 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -115,7 +115,7 @@ def models(self) -> List[lmfit.Model]: @property def plotter(self) -> BasePlotter: - """A short-cut for curve plotter instance.""" + """A short-cut to the curve plotter instance.""" return self._options.plotter @property diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index a9e83f09a8..efc22b909d 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -73,6 +73,20 @@ class BaseDrawer(ABC): This method is typically called with a list of analysis results and reduced chi-squared values from a curve-fit. + Options and Figure Options + ========================== + + Drawers have both :attr:`options` and :attr:`figure_options` available to set parameters that define + how to drawer and what is drawn. :class:`BasePlotter` is similar in that it also has ``options`` and + ``figure_options`. The former contains class-specific variables that define how an instance behaves. + The latter contains figure-specific variables that typically contain values that are drawn on the + canvas, such as text. For details on the difference between the two sets of options, see the documentation for :class:`BasePlotter`. + + .. note:: + If a drawer instance is used with a plotter, then there is the potential for any figure-option + to be overwritten with their value from the plotter. This means that the drawer instance would + be modified indirectly when the :meth:`BasePlotter.figure` method is called. This must be kept + in mind when creating subclasses of :class:`BaseDrawer`. """ def __init__(self): diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index a37de03536..f5aa041a96 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -41,7 +41,70 @@ class BasePlotter(ABC): instead only associated with the figure. Examples include analysis reports or other text that is drawn onto the figure canvas. - # TODO: Add example usage and description of options and figure-options. + Options and Figure Options + ========================== + + Plotters have both :attr:`options` and :attr:`figure_options` available to set parameters that define + how to plot and what is plotted. :class:`BaseDrawer` is similar in that it also has ``options`` and + ``figure_options`. The former contains class-specific variables that define how an instance behaves. + The latter contains figure-specific variables that typically contain values that are drawn on the + canvas, such as text. + + For example, :class:`BasePlotter` has an ``axis`` option that can be set to the canvas on which the + figure should be drawn. This changes how the plotter works in that it changes where the figure is + drawn. :class:`BasePlotter` has an ``xlabel`` figure-option that can be set to change the text drawn + next to the X-axis in the final figure. As the value of this option will be drawn on the figure, it + is a figure-option. + + As plotters need a drawer to generate a figure, and the drawer needs to know what to draw, + figure-options are passed to :attr:`drawer` when the :meth:`figure` method is called. Any + figure-options that are defined in both the plotters :attr:`figure_options` attribute and the drawers + ``figure_options`` attribute are copied to the drawer: i.e., :meth:`BaseDrawer.set_figure_options` is + called for each common figure-option, setting the value of the option to the value stored in the + plotter. + + .. note:: + If a figure-option called "foo" is not set in the drawers figure-options (:attr:`~BaseDrawer. + figure_options`), but is set in the plotters figure-options (:attr:`figure_options`), it will + not be copied over to the drawer when the :meth:`figure` method is called. This means that some + figure-options from the plotter may be unused by the drawer. :class:`BasePlotter` and its + subclasses filter these options before setting them in the drawer as subclasses of + :class:`BaseDrawer` may add additional figure-options. To make validation easier and the code + cleaner, the :meth:`figure` method conducts this check before setting figure-options in the + drawer. + + Example: + .. code-block:: python + plotter = MyPlotter(MyDrawer()) + + # MyDrawer contains the following figure_options with default values. + plotter.drawer.figure_options.xlabel + plotter.drawer.figure_options.ylabel + + # MyDrawer does NOT contain the following figure-option + # plotter.drawer.figure_options.unknown_variable # Raises an error as it does not exist in + # `plotter.drawer`. + + # If we set the following figure-options, they will be set in the drawer. + plotter.set_figure_options(xlabel="Frequency", ylabel="Fidelity") + + # During a call to `plotter.figure()`, the drawer figure-options are updated. + # The following values would be returned from the drawer. + plotter.drawer.figure_options.xlabel # returns "Frequency" + plotter.drawer.figure_options.ylabel # returns "Fidelity" + + # If we set the following option and figure-option will NOT be set in the drawer. + plotter.set_options(plot_fit=False) # Example plotter option + plotter.set_figure_options(unknown_variable=5e9) # Example figure-option + + # As `plot_fit` is not a figure-option, it is not set in the drawer. + plotter.drawer.options.plot_fit # Would raise an error if no default exists, or return a + # different value to `plotter.options.plot_fit`. + + # As `unknown_variable` is not set in the drawers figure-options, it is not set during a call + # to the `figure()` method. + # plotter.drawer.figure_options.unknown_variable # Raises an error as it does not exist + # in `plotter.drawer.figure_options`. """ def __init__(self, drawer: BaseDrawer): From 803512177c4a47407a44484229abce4cf8d4912f Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 22:38:06 +0200 Subject: [PATCH 26/45] Plotter and drawer code cleanup --- .../visualization/plotters/base_plotter.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index f5aa041a96..b10c252939 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -129,17 +129,7 @@ def __init__(self, drawer: BaseDrawer): self._set_figure_options = set() # The drawer backend to use for plotting. - self._drawer = drawer - - @property - def drawer(self) -> BaseDrawer: - """The drawer being used by the plotter.""" - return self._drawer - - @drawer.setter - def drawer(self, new_drawer: BaseDrawer): - """Set the drawer to be used by the plotter.""" - self._drawer = new_drawer + self.drawer = drawer @property def figure_data(self) -> Dict[str, Any]: @@ -181,9 +171,7 @@ def data_keys_for(self, series_name: str) -> List[str]: list: The list of data-keys for data in the plotter associated with the given series. If the series has not been added to the plotter, an empty list is returned. """ - if series_name not in self._series_data: - return [] - return list(self._series_data[series_name]) + return list(self._series_data.get(series_name, [])) def data_for(self, series_name: str, data_keys: Union[str, List[str]]) -> Tuple[Optional[Any]]: """Returns data associated with the given series. From 88d278490be2df13d88b075e4cf8ca6600e48014 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 26 Sep 2022 22:44:59 +0200 Subject: [PATCH 27/45] Fix lint --- .../visualization/drawers/base_drawer.py | 15 ++++++++++++--- .../visualization/plotters/base_plotter.py | 3 +++ test/visualization/test_style.py | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index efc22b909d..82f7524733 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -80,7 +80,8 @@ class BaseDrawer(ABC): how to drawer and what is drawn. :class:`BasePlotter` is similar in that it also has ``options`` and ``figure_options`. The former contains class-specific variables that define how an instance behaves. The latter contains figure-specific variables that typically contain values that are drawn on the - canvas, such as text. For details on the difference between the two sets of options, see the documentation for :class:`BasePlotter`. + canvas, such as text. For details on the difference between the two sets of options, see the + documentation for :class:`BasePlotter`. .. note:: If a drawer instance is used with a plotter, then there is the potential for any figure-option @@ -190,8 +191,12 @@ def _default_figure_options(cls) -> Options: def set_options(self, **fields): """Set the drawer options. + Args: fields: The fields to update the options + + Raises: + AttributeError: if an unknown options is encountered. """ for field in fields: if not hasattr(self._options, field): @@ -204,8 +209,12 @@ def set_options(self, **fields): def set_figure_options(self, **fields): """Set the figure options. + Args: fields: The fields to update the figure options + + Raises: + AttributeError: if an unknown figure-option is encountered. """ for field in fields: if not hasattr(self._figure_options, field): @@ -220,8 +229,8 @@ def style(self) -> PlotStyle: """The combined plot style for this drawer. The returned style instance is a combination of :attr:`options.default_style` and - :attr:`figure_options.custom_style`. Style parameters set in ``custom_style`` override those set in - ``default_style``. If ``custom_style`` is not an instance of :class:`PlotStyle`, the returned + :attr:`figure_options.custom_style`. Style parameters set in ``custom_style`` override those set + in ``default_style``. If ``custom_style`` is not an instance of :class:`PlotStyle`, the returned style is equivalent to ``default_style``. Returns: diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index b10c252939..9a65a6329a 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -422,6 +422,9 @@ def set_options(self, **fields): Args: fields: The fields to update in options. + + Raises: + AttributeError: if an unknown option is encountered. """ for field in fields: if not hasattr(self._options, field): diff --git a/test/visualization/test_style.py b/test/visualization/test_style.py index 1354673529..d9f5c513de 100644 --- a/test/visualization/test_style.py +++ b/test/visualization/test_style.py @@ -136,7 +136,7 @@ def test_update_dict(self): self.assertDictEqual(actual_12.__dict__, expected_12.__dict__) self.assertDictEqual(actual_21.__dict__, expected_21.__dict__) - def test_merge(self): + def test_merge_dict(self): """Test that PlotStyle dictionary is correct when merged.""" custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() From 99bc4a67eb6c7dc9a9804a4f881115fefa61f767 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Tue, 27 Sep 2022 10:45:30 +0200 Subject: [PATCH 28/45] Fix tests failing because of deprecation warning in curve_analysis.visualization --- .../curve_analysis/visualization/base_drawer.py | 2 +- .../curve_analysis/visualization/curves.py | 6 +++--- .../visualization/fit_result_plotters.py | 4 ++-- test/base.py | 15 +++++++++++++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/qiskit_experiments/curve_analysis/visualization/base_drawer.py b/qiskit_experiments/curve_analysis/visualization/base_drawer.py index f4993788bc..25af22c764 100644 --- a/qiskit_experiments/curve_analysis/visualization/base_drawer.py +++ b/qiskit_experiments/curve_analysis/visualization/base_drawer.py @@ -21,7 +21,7 @@ @deprecated_class( "0.6", - msg="Plotting and drawing of analysis figures has been moved to the new" + msg="Plotting and drawing of analysis figures has been moved to the new " "`qiskit_experiments.visualization` module.", ) class BaseCurveDrawer(ABC): diff --git a/qiskit_experiments/curve_analysis/visualization/curves.py b/qiskit_experiments/curve_analysis/visualization/curves.py index e49ed03f87..d03e8d1dcf 100644 --- a/qiskit_experiments/curve_analysis/visualization/curves.py +++ b/qiskit_experiments/curve_analysis/visualization/curves.py @@ -24,7 +24,7 @@ @deprecated_function( "0.6", - msg="Plotting and drawing functionality has been moved to the new" + msg="Plotting and drawing functionality has been moved to the new " "`qiskit_experiments.visualization` module.", ) def plot_curve_fit( @@ -103,7 +103,7 @@ def plot_curve_fit( @deprecated_function( "0.6", - msg="Plotting and drawing functionality has been moved to the new" + msg="Plotting and drawing functionality has been moved to the new " "`qiskit_experiments.visualization` module.", ) def plot_scatter( @@ -152,7 +152,7 @@ def plot_scatter( @deprecated_function( "0.6", - msg="Plotting and drawing functionality has been moved to the new" + msg="Plotting and drawing functionality has been moved to the new " "`qiskit_experiments.visualization` module.", ) def plot_errorbar( diff --git a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py index fd77ce035a..b597368fd7 100644 --- a/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py +++ b/qiskit_experiments/curve_analysis/visualization/fit_result_plotters.py @@ -41,7 +41,7 @@ @deprecated_class( "0.6", - msg="Plotting and drawing of analysis figures has been moved to the new" + msg="Plotting and drawing of analysis figures has been moved to the new " "`qiskit_experiments.visualization` module.", ) class MplDrawSingleCanvas: @@ -429,7 +429,7 @@ def format_val(float_val: float) -> str: # pylint: disable=invalid-name @deprecated_class( "0.6", - msg="Plotting and drawing of analysis figures has been moved to the new" + msg="Plotting and drawing of analysis figures has been moved to the new " "`qiskit_experiments.visualization` module.", ) class FitResultPlotters(Enum): diff --git a/test/base.py b/test/base.py index 301f1a56bd..514e9007e2 100644 --- a/test/base.py +++ b/test/base.py @@ -39,6 +39,21 @@ class QiskitExperimentsTestCase(QiskitTestCase): """Qiskit Experiments specific extra functionality for test cases.""" + @classmethod + def setUpClass(cls): + """Set-up test class.""" + super().setUpClass() + + # Some functionality may be deprecated in Qiskit Experiments. If the deprecation warnings aren't + # filtered, the tests will fail as ``QiskitTestCase`` sets all warnings to be treated as an error + # by default. + allow_DeprecationWarning_message = [ + # TODO: Remove in 0.6, when submodule `.curve_analysis.visualization` is removed. + r".*Plotting and drawing functionality has been moved", + ] + for msg in allow_DeprecationWarning_message: + warnings.filterwarnings("default", category=DeprecationWarning, message=msg) + def assertExperimentDone( self, experiment_data: ExperimentData, From 28a8c80038c4c0732635c389637ee71aa5ab6294 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Tue, 27 Sep 2022 11:40:30 +0200 Subject: [PATCH 29/45] Add compatibility wrapper for legacy BaseCurveDrawer to support deprecated .drawer properties --- .../curve_analysis/base_curve_analysis.py | 39 ++-- .../composite_curve_analysis.py | 40 ++-- qiskit_experiments/visualization/__init__.py | 9 +- .../visualization/drawers/__init__.py | 1 + .../drawers/legacy_curve_compat_drawer.py | 186 ++++++++++++++++++ test/base.py | 6 +- 6 files changed, 246 insertions(+), 35 deletions(-) create mode 100644 qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py diff --git a/qiskit_experiments/curve_analysis/base_curve_analysis.py b/qiskit_experiments/curve_analysis/base_curve_analysis.py index bbc098300d..a8befb06cc 100644 --- a/qiskit_experiments/curve_analysis/base_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/base_curve_analysis.py @@ -16,16 +16,28 @@ import warnings from abc import ABC, abstractmethod -from typing import List, Dict, Union +from typing import Dict, List, Union import lmfit from qiskit_experiments.data_processing import DataProcessor from qiskit_experiments.data_processing.processor_library import get_processor -from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData, Options, ExperimentData +from qiskit_experiments.framework import ( + AnalysisResultData, + BaseAnalysis, + ExperimentData, + Options, +) +from qiskit_experiments.visualization import ( + BaseDrawer, + BasePlotter, + CurvePlotter, + LegacyCurveCompatDrawer, + MplDrawer, +) from qiskit_experiments.warnings import deprecated_function -from qiskit_experiments.visualization import MplDrawer, BasePlotter, CurvePlotter, BaseDrawer -from .curve_data import CurveData, ParameterRepr, CurveFitResult + +from .curve_data import CurveData, CurveFitResult, ParameterRepr PARAMS_ENTRY_PREFIX = "@Parameters_" DATA_ENTRY_PREFIX = "@Data_" @@ -125,8 +137,8 @@ def plotter(self) -> BasePlotter: ) def drawer(self) -> BaseDrawer: """A short-cut for curve drawer instance, if set. ``None`` otherwise.""" - if hasattr(self._options, "curve_drawer"): - return self._options.curve_drawer + if isinstance(self.plotter.drawer, LegacyCurveCompatDrawer): + return self.plotter.drawer._curve_drawer else: return None @@ -232,21 +244,18 @@ def set_options(self, **fields): DeprecationWarning, stacklevel=2, ) - # Set the plotter drawer to `curve_drawer`, though it needs to be a subclass of the new class - # `BaseDrawer` from `qiskit_experiments.visualization`. + # Set the plotter drawer to `curve_drawer`. If `curve_drawer` is the right type, set it + # directly. If not, wrap it in a compatibility drawer. if isinstance(fields["curve_drawer"], BaseDrawer): plotter = self.options.plotter plotter.drawer = fields.pop("curve_drawer") fields["plotter"] = plotter else: - # TODO Add usage of wrapper class for backwards compatibility during deprecation period. drawer = fields["curve_drawer"] - warnings.warn( - "Cannot set deprecated `curve_drawer` options as it is not a subclass of " - f"`BaseDrawer`: got type {type(drawer).__name__}. Doing nothing.", - DeprecationWarning, - stacklevel=2, - ) + compat_drawer = LegacyCurveCompatDrawer(drawer) + plotter = self.options.plotter + plotter.drawer = compat_drawer + fields["plotter"] = plotter super().set_options(**fields) diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index 8c03e87cf6..229c8dc953 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -15,16 +15,29 @@ """ # pylint: disable=invalid-name import warnings -from typing import Dict, List, Tuple, Optional, Union +from typing import Dict, List, Optional, Tuple, Union import lmfit import numpy as np -from uncertainties import unumpy as unp, UFloat - -from qiskit_experiments.framework import BaseAnalysis, ExperimentData, AnalysisResultData, Options +from uncertainties import UFloat +from uncertainties import unumpy as unp + +from qiskit_experiments.framework import ( + AnalysisResultData, + BaseAnalysis, + ExperimentData, + Options, +) +from qiskit_experiments.visualization import ( + BaseDrawer, + BasePlotter, + CurvePlotter, + LegacyCurveCompatDrawer, + MplDrawer, +) from qiskit_experiments.warnings import deprecated_function -from qiskit_experiments.visualization import MplDrawer, CurvePlotter, BasePlotter, BaseDrawer -from .base_curve_analysis import BaseCurveAnalysis, PARAMS_ENTRY_PREFIX + +from .base_curve_analysis import PARAMS_ENTRY_PREFIX, BaseCurveAnalysis from .curve_data import CurveFitResult from .utils import analysis_result_to_repr, eval_with_uncertainties @@ -234,21 +247,18 @@ def set_options(self, **fields): DeprecationWarning, stacklevel=2, ) - # Set the plotter drawer to `curve_drawer`, though it needs to be a subclass of the new class - # `BaseDrawer` from `qiskit_experiments.visualization`. + # Set the plotter drawer to `curve_drawer`. If `curve_drawer` is the right type, set it + # directly. If not, wrap it in a compatibility drawer. if isinstance(fields["curve_drawer"], BaseDrawer): plotter = self.options.plotter plotter.drawer = fields.pop("curve_drawer") fields["plotter"] = plotter else: - # TODO Add usage of wrapper class for backwards compatibility during deprecation period. drawer = fields["curve_drawer"] - warnings.warn( - "Cannot set deprecated `curve_drawer` options as it is not a subclass of " - f"`BaseDrawer`: got type {type(drawer).__name__}. Doing nothing.", - DeprecationWarning, - stacklevel=2, - ) + compat_drawer = LegacyCurveCompatDrawer(drawer) + plotter = self.options.plotter + plotter.drawer = compat_drawer + fields["plotter"] = plotter for field in fields: if not hasattr(self.options, field): diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index 799dd6e451..ff42fabb4d 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -42,7 +42,8 @@ :toctree: ../stubs/ :template: autosummary/class.rst - BasePlotter CurvePlotter + BasePlotter + CurvePlotter Drawer Library ============== @@ -51,7 +52,9 @@ :toctree: ../stubs/ :template: autosummary/class.rst - BaseDrawer MplDrawer + BaseDrawer + MplDrawer + LegacyCurveCompatDrawer Plotting Style ============== @@ -66,5 +69,5 @@ # PlotStyle is imported by .drawers and .plotters. Skip PlotStyle import for isort to prevent circular # import. from .style import PlotStyle # isort:skip -from .drawers import BaseDrawer, MplDrawer +from .drawers import BaseDrawer, LegacyCurveCompatDrawer, MplDrawer from .plotters import BasePlotter, CurvePlotter diff --git a/qiskit_experiments/visualization/drawers/__init__.py b/qiskit_experiments/visualization/drawers/__init__.py index 6f3a37504c..1e67ce2688 100644 --- a/qiskit_experiments/visualization/drawers/__init__.py +++ b/qiskit_experiments/visualization/drawers/__init__.py @@ -12,4 +12,5 @@ """Drawers submodule, defining interfaces to figure backends.""" from .base_drawer import BaseDrawer +from .legacy_curve_compat_drawer import LegacyCurveCompatDrawer from .mpl_drawer import MplDrawer diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py new file mode 100644 index 0000000000..27f5c4b112 --- /dev/null +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -0,0 +1,186 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2022. +# +# 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. + +"""Compatibility wrapper for legacy BaseCurveDrawer.""" + +import warnings +from typing import Optional, Sequence, Tuple + +from qiskit_experiments.curve_analysis.visualization import BaseCurveDrawer +from qiskit_experiments.warnings import deprecated_class + +from .base_drawer import BaseDrawer + + +@deprecated_class( + "0.6", + msg="Legacy drawers from `.curve_analysis.visualization are deprecated. This compatibility wrapper " + "will be removed alongside the deprecated modules removal", +) +class LegacyCurveCompatDrawer(BaseDrawer): + """A compatibility wrapper for the legacy and deprecated :class:`BaseCurveDrawer`. + + :mod:`qiskit_experiments.curve_analysis.visualization` is deprecated and will be replaced with the + new :mod:`qiskit_experiments.visualization` module. Analysis classes instead use subclasses of + :class:`BasePlotter` to generate figures. This class wraps the legacy :class:`BaseCurveDrawer` class + so it can be used by analysis classes, such as :class:`CurveAnalysis`, until it is removed. + """ + + def __init__(self, curve_drawer: BaseCurveDrawer): + """Create a LegacyCurveCompatDrawer instance. + + Args: + curve_drawer: A legacy BaseCurveDrawer to wrap in the compatibility drawer. + """ + super().__init__() + self._curve_drawer = curve_drawer + + def initialize_canvas(self): + self._curve_drawer.initialize_canvas() + + def format_canvas(self): + self._curve_drawer.format_canvas() + + # pylint: disable=unused-argument + def draw_scatter( + self, + x_data: Sequence[float], + y_data: Sequence[float], + x_err: Optional[Sequence[float]] = None, + y_err: Optional[Sequence[float]] = None, + name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, + **options, + ): + """Draws scatter points with optional Y errorbars. + + Args: + x_data: X values. + y_data: Y values. + x_err: Unsupported as :class:`BaseCurveDrawer` doesn't support X + errorbars. Defaults to None. + y_err: Optional error for Y values. + name: Name of this series. + legend_entry: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend + entries. + legend_label: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend + entries. + options: Valid options for the drawer backend API. + """ + if x_err is not None: + warnings.warn(f"{self.__class__.__name__} doesn't support x_err.") + + if y_err is not None: + self._curve_drawer.draw_formatted_data(x_data, y_data, y_err, name, **options) + else: + self._curve_drawer.draw_raw_data(x_data, y_data, name, **options) + + # pylint: disable=unused-argument + def draw_line( + self, + x_data: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, + **options, + ): + """Draw fit line. + + Args: + x_data: X values. + y_data: Fit Y values. + name: Name of this series. + legend_entry: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend + entries. + legend_label: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend + entries. + options: Valid options for the drawer backend API. + """ + self._curve_drawer.draw_fit_line(x_data, y_data, name, **options) + + # pylint: disable=unused-argument + def draw_filled_y_area( + self, + x_data: Sequence[float], + y_ub: Sequence[float], + y_lb: Sequence[float], + name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, + **options, + ): + """Draw filled area as a function of x-values. + + Args: + x_data: X values. + y_ub: The upper boundary of Y values. + y_lb: The lower boundary of Y values. + name: Name of this series. + legend_entry: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend + entries. + legend_label: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend + entries. + options: Valid options for the drawer backend API. + """ + + self._curve_drawer.draw_confidence_interval(x_data, y_ub, y_lb, name, **options) + + # pylint: disable=unused-argument + def draw_filled_x_area( + self, + x_ub: Sequence[float], + x_lb: Sequence[float], + y_data: Sequence[float], + name: Optional[str] = None, + legend_entry: bool = False, + legend_label: Optional[str] = None, + **options, + ): + """Does nothing as this is functionality not supported by :class:`BaseCurveDrawer`.""" + warnings.warn(f"{self.__class__.__name__}.draw_filled_x_area is not supported.") + + # pylint: disable=unused-argument + def draw_text_box( + self, description: str, rel_pos: Optional[Tuple[float, float]] = None, **options + ): + """Draw text box. + + Args: + description: A string to be drawn inside a report box. + rel_pos: Unsupported as :class:`BaseCurveDrawer` doesn't support modifying the location of + text in :meth:`draw_text_box` or :meth:`BaseCurveDrawer.draw_fit_report`. + options: Valid options for the drawer backend API. + """ + + self._curve_drawer.draw_fit_report(description, **options) + + @property + def figure(self): + return self._curve_drawer.figure + + def set_options(self, **fields): + ## Handle option name changes + # BaseCurveDrawer used `plot_options` instead of `series_params` + if "series_params" in fields: + fields["plot_options"] = fields.pop("series_params") + # PlotStyle parameters are normal options in BaseCurveDrawer. + if "custom_style" in fields: + custom_style = fields.pop("custom_style") + for key, value in custom_style.__dict__.items(): + fields[key] = value + + self._curve_drawer.set_options(**fields) + + def set_figure_options(self, **fields): + self.set_options(**fields) diff --git a/test/base.py b/test/base.py index 514e9007e2..41915c2b8d 100644 --- a/test/base.py +++ b/test/base.py @@ -47,11 +47,13 @@ def setUpClass(cls): # Some functionality may be deprecated in Qiskit Experiments. If the deprecation warnings aren't # filtered, the tests will fail as ``QiskitTestCase`` sets all warnings to be treated as an error # by default. - allow_DeprecationWarning_message = [ + # pylint: disable=invalid-name + allow_deprecationwarning_message = [ # TODO: Remove in 0.6, when submodule `.curve_analysis.visualization` is removed. r".*Plotting and drawing functionality has been moved", + r".*Legacy drawers from `.curve_analysis.visualization are deprecated", ] - for msg in allow_DeprecationWarning_message: + for msg in allow_deprecationwarning_message: warnings.filterwarnings("default", category=DeprecationWarning, message=msg) def assertExperimentDone( From 544e4a29e0035607b25413c09e6748fc3474e453 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Tue, 27 Sep 2022 13:13:53 +0200 Subject: [PATCH 30/45] Update BaseDrawer docstrings --- .../visualization/drawers/base_drawer.py | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 82f7524733..ee088ce779 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -23,7 +23,8 @@ class BaseDrawer(ABC): """Abstract class for the serializable Qiskit Experiments figure drawer. A drawer may be implemented by different drawer backends such as matplotlib or Plotly. Sub-classes - that wrap these backends by subclassing `BaseDrawer` must implement the following abstract methods. + that wrap these backends by subclassing :class:`BaseDrawer` must implement the following abstract + methods. initialize_canvas @@ -40,36 +41,35 @@ class BaseDrawer(ABC): format_canvas - This method formats the appearance of the canvas. Typically, it updates - axis and tick labels. Note that the axis SI unit may be specified in the drawer options. In this - case, axis numbers should be auto-scaled with the unit prefix. + This method formats the appearance of the canvas. Typically, it updates axis and tick labels. + Note that the axis SI unit may be specified in the drawer figure_options. In this case, axis + numbers should be auto-scaled with the unit prefix. - draw_raw_data + draw_scatter - This method draws raw experiment data points on the canvas, like a scatter-plot. - - draw_formatted_data - - This method plots data with error-bars for the y-values. The formatted data might be averaged - over the same x values, or smoothed by a filtering algorithm, depending on how analysis class is - implemented. This method is called with error bars of y values and the name of the series. + This method draws scatter points on the canvas, like a scatter-plot, with optional error-bars in + both the X and Y axes. draw_line This method plots a line from provided X and Y values. This method is typically called with interpolated x and y values from a curve-fit. - draw_confidence_interval + draw_filled_y_area This method plots a shaped region bounded by upper and lower Y-values. This method is typically called with interpolated x and a pair of y values that represent the upper and lower bound within - certain confidence interval. This might be called multiple times with different interval sizes. - It is normally good to set some transparency for a confidence interval so the figure has enough - contrast between points, lines, and the confidence-interval shape. + certain confidence interval. If this is called multiple times, it may be necessary to set the + transparency so that overlapping regions can be distinguished. + + draw_filled_x_area + + This method plots a shaped region bounded by upper and lower X-values, as a function of Y-values. + This method is a rotated analogue of :meth:`draw_filled_y_area`. - draw_report + draw_text_box - This method draws a report on the canvas, which is a rectangular region containing some text. + This method draws a text-box on the canvas, which is a rectangular region containing some text. This method is typically called with a list of analysis results and reduced chi-squared values from a curve-fit. From 288dcb6f94d70b29779e68fef3c6faaf8a8731ce Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 29 Sep 2022 18:43:51 +0200 Subject: [PATCH 31/45] Revert curve_analysis module docs to same as main, given visualization submodule is not yet removed --- qiskit_experiments/curve_analysis/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/qiskit_experiments/curve_analysis/__init__.py b/qiskit_experiments/curve_analysis/__init__.py index 1b5dcb4b95..73253f71a6 100644 --- a/qiskit_experiments/curve_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/__init__.py @@ -481,6 +481,15 @@ def _create_analysis_results(self, fit_data, quality, **metadata): ParameterRepr FitOptions +Visualization +============= + +.. autosummary:: + :toctree: ../stubs/ + + BaseCurveDrawer + MplCurveDrawer + Standard Analysis Library ========================= @@ -556,6 +565,7 @@ def _create_analysis_results(self, fit_data, quality, **metadata): process_curve_data, process_multi_curve_data, ) +from .visualization import BaseCurveDrawer, MplCurveDrawer from . import guess from . import fit_function from . import utils @@ -570,3 +580,6 @@ def _create_analysis_results(self, fit_data, quality, **metadata): ErrorAmplificationAnalysis, BlochTrajectoryAnalysis, ) + +# deprecated +from .visualization import plot_curve_fit, plot_errorbar, plot_scatter, FitResultPlotters From b6043501fc86dbdcbe52e414917d07a497e73fa5 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Fri, 30 Sep 2022 13:45:12 +0200 Subject: [PATCH 32/45] Update legend generation to use interface defined in #926 Coauthor added as commit is based on code from #926. Co-authored-by: Naoki Kanazawa --- .../curve_analysis/curve_analysis.py | 1 + .../analysis/cr_hamiltonian_analysis.py | 42 ++++++- .../visualization/drawers/base_drawer.py | 106 ++++++++++++++---- .../drawers/legacy_curve_compat_drawer.py | 42 ++++--- .../visualization/drawers/mpl_drawer.py | 54 ++++----- .../visualization/plotters/curve_plotter.py | 7 +- test/visualization/mock_drawer.py | 16 +-- 7 files changed, 183 insertions(+), 85 deletions(-) diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index d5868dd511..97bdabbc3a 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -153,6 +153,7 @@ def __init__( "color": series_def.plot_color, "symbol": series_def.plot_symbol, "canvas": series_def.canvas, + "label": series_def.name, } self.plotter.set_figure_options(series_params=series_params) diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index 3de281fb06..fa060a70df 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -79,12 +79,42 @@ def _default_options(cls): xval_unit="s", ylim=(-1, 1), series_params={ - "x_ctrl0": {"color": "blue", "symbol": "o", "canvas": 0}, - "y_ctrl0": {"color": "blue", "symbol": "o", "canvas": 1}, - "z_ctrl0": {"color": "blue", "symbol": "o", "canvas": 2}, - "x_ctrl1": {"color": "red", "symbol": "^", "canvas": 0}, - "y_ctrl1": {"color": "red", "symbol": "^", "canvas": 1}, - "z_ctrl1": {"color": "red", "symbol": "^", "canvas": 2}, + "x_ctrl0": { + "canvas": 0, + "color": "blue", + "label": "X (ctrl0)", + "symbol": "o", + }, + "y_ctrl0": { + "canvas": 1, + "color": "blue", + "label": "Y (ctrl0)", + "symbol": "o", + }, + "z_ctrl0": { + "canvas": 2, + "color": "blue", + "label": "Z (ctrl0)", + "symbol": "o", + }, + "x_ctrl1": { + "canvas": 0, + "color": "red", + "label": "X (ctrl1)", + "symbol": "^", + }, + "y_ctrl1": { + "canvas": 1, + "color": "red", + "label": "Y (ctrl1)", + "symbol": "^", + }, + "z_ctrl1": { + "canvas": 2, + "color": "red", + "label": "Z (ctrl1)", + "symbol": "^", + }, }, ) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index ee088ce779..f21cb256bb 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -88,6 +88,30 @@ class BaseDrawer(ABC): to be overwritten with their value from the plotter. This means that the drawer instance would be modified indirectly when the :meth:`BasePlotter.figure` method is called. This must be kept in mind when creating subclasses of :class:`BaseDrawer`. + + Legends + ======= + + Legends are generated based off of drawn graphics and their labels or names. These are managed by + individual drawer subclasses, and generated when the :meth:`format_canvas` method is called. Legend + entries are created when any ``draw_*`` function is called with ``legend=True``. There are three + parameters in ``draw_*`` functions that are relevant to legend generation: ``name``, ``label``, and + ``legend``. If a user would like the graphics drawn onto a canvas, by a call to ``draw_*``, to be + used as the graphical component of a legend entry; they should set ``legend=True``. The legend entry + label can be defined in three locations: the ``label`` parameter of ``draw_*`` functions, the + ``"label"`` entry in ``series_params``, and the ``name`` parameter of ``draw_*`` functions. These + three possible label variables have a search hierarchy given by the order in the aforementioned list. + If one of the label variables is ``None``, the next is used. If all are ``None``, a legend entry is + not generated for the given series. + + The recommended way to customize the legend entries is as follows: + 1. Set the labels in the ``series_params`` option, keyed on the series names. + 2. Initialize the canvas. + 3. Call relevant ``draw_*`` methods to create the figure. When calling the ``draw_*`` method that + creates the graphic you would like to use in the legend, set ``legend=True``. For example, + ``drawer.draw_scatter(...,legend=True)`` would use the scatter points as the legend graphics + for the given series. + 4. Format the canvas and call :meth:`figure` to get the figure. """ def __init__(self): @@ -169,10 +193,11 @@ def _default_figure_options(cls) -> Options: yval_unit (str): Unit of y values. See ``xval_unit`` for details. figure_title (str): Title of the figure. Defaults to None, i.e. nothing is shown. series_params (Dict[str, Dict[str, Any]]): A dictionary of parameters for each series. - This is keyed on the name for each series. Sub-dictionary is expected to have following - three configurations, "canvas", "color", and "symbol"; "canvas" is the integer index of - axis (when multi-canvas plot is set), "color" is the color of the series, and "symbol" is - the marker style of the series. Defaults to an empty dictionary. + This is keyed on the name for each series. Sub-dictionary is expected to have the + following three configurations, "canvas", "color", "symbol" and "label"; "canvas" is the + integer index of axis (when multi-canvas plot is set), "color" is the color of the drawn + graphics, "symbol" is the series marker style for scatter plots, and "label" is a user + provided series label that appears in the legend. custom_style (PlotStyle): The style definition to use when drawing. This overwrites style parameters in ``default_style`` in :attr:`options`. Defaults to an empty PlotStyle instance (i.e., :code-block:`PlotStyle()`). @@ -248,6 +273,31 @@ def initialize_canvas(self): def format_canvas(self): """Final cleanup for the canvas appearance.""" + def label_for(self, name: Optional[str], label: Optional[str]) -> Optional[str]: + """Get the legend label for the given series, with optional overrides. + + This method determines the legend label for a series, with optional overrides ``label`` and the + ``"label"`` entry in the ``series_params`` option (see :attr:`options`). ``label`` is returned if + it is not ``None``, as this is the override with the highest priority. If it is ``None``, then + the drawer will look for a ``"label"`` entry in ``series_params`, for the series identified by + ``name``. If this entry doesn't exist, or is ``None``, then ``name`` is used as the label. If all + these options are ``None``, then ``None`` is returned; signifying that a legend entry for the + provided series should not be generated. + + Args: + name: The name of the series. + label: Optional label override. + + Returns: + Optional[str]: The legend entry label, or ``None``. + """ + if label is not None: + return label + + if name: + return self.figure_options.series_params.get(name, {}).get("label", name) + return None + @abstractmethod def draw_scatter( self, @@ -256,8 +306,8 @@ def draw_scatter( x_err: Optional[Sequence[float]] = None, y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draw scatter points, with optional error-bars. @@ -268,8 +318,12 @@ def draw_scatter( x_err: Optional error for X values. y_err: Optional error for Y values. name: Name of this series. - legend_entry: Whether the drawn area must have a legend entry. Defaults to False. - legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. + label: Optional legend label to override ``name`` and ``series_params``. + legend: Whether the drawn area must have a legend entry. Defaults to False. + The series label in the legend will be ``label`` if it is not None. If it is, then + ``series_params`` is searched for a "label" entry for the series identified by ``name``. + If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is + provided, then no legend entry is generated. options: Valid options for the drawer backend API. """ @@ -279,8 +333,8 @@ def draw_line( x_data: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draw fit line. @@ -289,8 +343,12 @@ def draw_line( x_data: X values. y_data: Fit Y values. name: Name of this series. - legend_entry: Whether the drawn area must have a legend entry. Defaults to False. - legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. + label: Optional legend label to override ``name`` and ``series_params``. + legend: Whether the drawn area must have a legend entry. Defaults to False. + The series label in the legend will be ``label`` if it is not None. If it is, then + ``series_params`` is searched for a "label" entry for the series identified by ``name``. + If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is + provided, then no legend entry is generated. options: Valid options for the drawer backend API. """ @@ -301,8 +359,8 @@ def draw_filled_y_area( y_ub: Sequence[float], y_lb: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draw filled area as a function of x-values. @@ -312,8 +370,12 @@ def draw_filled_y_area( y_ub: The upper boundary of Y values. y_lb: The lower boundary of Y values. name: Name of this series. - legend_entry: Whether the drawn area must have a legend entry. Defaults to False. - legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. + label: Optional legend label to override ``name`` and ``series_params``. + legend: Whether the drawn area must have a legend entry. Defaults to False. + The series label in the legend will be ``label`` if it is not None. If it is, then + ``series_params`` is searched for a "label" entry for the series identified by ``name``. + If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is + provided, then no legend entry is generated. options: Valid options for the drawer backend API. """ @@ -324,8 +386,8 @@ def draw_filled_x_area( x_lb: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draw filled area as a function of y-values. @@ -335,8 +397,12 @@ def draw_filled_x_area( x_lb: The lower boundary of X values. y_data: Y values. name: Name of this series. - legend_entry: Whether the drawn area must have a legend entry. Defaults to False. - legend_label: Optional legend label. ``name`` will be used if ``legend_label` is None. + label: Optional legend label to override ``name`` and ``series_params``. + legend: Whether the drawn area must have a legend entry. Defaults to False. + The series label in the legend will be ``label`` if it is not None. If it is, then + ``series_params`` is searched for a "label" entry for the series identified by ``name``. + If this is also ``None``, then ``name`` is used as the fallback. If no ``name`` is + provided, then no legend entry is generated. options: Valid options for the drawer backend API. """ diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index 27f5c4b112..a84a186906 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -33,6 +33,11 @@ class LegacyCurveCompatDrawer(BaseDrawer): new :mod:`qiskit_experiments.visualization` module. Analysis classes instead use subclasses of :class:`BasePlotter` to generate figures. This class wraps the legacy :class:`BaseCurveDrawer` class so it can be used by analysis classes, such as :class:`CurveAnalysis`, until it is removed. + + .. note:: + As :class:`BaseCurveDrawer` doesn't support customizing legend entries, the ``legend`` and + ``label`` parameters in ``draw_*`` methods (such as :meth:`draw_scatter`) are unsupported and + do nothing. """ def __init__(self, curve_drawer: BaseCurveDrawer): @@ -58,8 +63,8 @@ def draw_scatter( x_err: Optional[Sequence[float]] = None, y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draws scatter points with optional Y errorbars. @@ -67,14 +72,11 @@ def draw_scatter( Args: x_data: X values. y_data: Y values. - x_err: Unsupported as :class:`BaseCurveDrawer` doesn't support X - errorbars. Defaults to None. + x_err: Unsupported as :class:`BaseCurveDrawer` doesn't support X errorbars. Defaults to None. y_err: Optional error for Y values. name: Name of this series. - legend_entry: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend - entries. - legend_label: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend - entries. + label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing legend entries. + legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend entries. options: Valid options for the drawer backend API. """ if x_err is not None: @@ -91,8 +93,8 @@ def draw_line( x_data: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draw fit line. @@ -101,10 +103,8 @@ def draw_line( x_data: X values. y_data: Fit Y values. name: Name of this series. - legend_entry: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend - entries. - legend_label: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend - entries. + label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing legend entries. + legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend entries. options: Valid options for the drawer backend API. """ self._curve_drawer.draw_fit_line(x_data, y_data, name, **options) @@ -116,8 +116,8 @@ def draw_filled_y_area( y_ub: Sequence[float], y_lb: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Draw filled area as a function of x-values. @@ -127,10 +127,8 @@ def draw_filled_y_area( y_ub: The upper boundary of Y values. y_lb: The lower boundary of Y values. name: Name of this series. - legend_entry: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend - entries. - legend_label: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend - entries. + label: Unsupported as :class:`BaseCurveDrawer` doesn't support customizing legend entries. + legend: Unsupported as :class:`BaseCurveDrawer` doesn't support toggling legend entries. options: Valid options for the drawer backend API. """ @@ -143,8 +141,8 @@ def draw_filled_x_area( x_lb: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Does nothing as this is functionality not supported by :class:`BaseCurveDrawer`.""" diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 97dd50fd06..1d84267933 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -272,29 +272,31 @@ def _get_default_marker(self, name: str) -> str: ind = self._series.index(name) % len(self.DefaultMarkers) return self.DefaultMarkers[ind] - def _update_label_in_dict( + def _update_label_in_options( self, options: Dict[str, any], name: Optional[str], - legend_entry: bool, - legend_label: Optional[str], + label: Optional[str] = None, + legend: bool = False, ): """Helper function to set the label entry in ``options`` based on given arguments. + This method uses :meth:`label_for` to get the label for the series identified by ``name``. If + :meth:`label_for` returns ``None``, then ``_update_label_in_options`` doesn't add a `"label"` + entry into ``options``. I.e., a label entry is added to ``options`` only if it is not ``None``. + Args: options: The options dictionary being modified. - name: A fall-back label if ``legend_label`` is None. If None, a blank string is used. - legend_entry: Whether to set "label" in ``options``. - legend_label: Optional label. If None, ``name`` is used. + name: The name of the series being labelled. Used as a fall-back label if ``label`` is None + and no label exists in ``series_params`` for this series. + label: Optional legend label to override ``name`` and ``series_params``. + legend: Whether a label entry should be added to ``options``. USed as an easy toggle to + disable adding a label entry. Defaults to False. """ - if legend_entry: - if legend_label is not None: - label = legend_label - elif name is not None: - label = name - else: - label = "" - options["label"] = label + if legend: + _label = self.label_for(name, label) + if _label: + options["label"] = _label def draw_scatter( self, @@ -303,8 +305,8 @@ def draw_scatter( x_err: Optional[Sequence[float]] = None, y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): @@ -319,7 +321,7 @@ def draw_scatter( "alpha": 0.8, "zorder": 2, } - self._update_label_in_dict(draw_options, name, legend_entry, legend_label) + self._update_label_in_options(draw_options, name, label, legend) draw_options.update(**options) if x_err is None and y_err is None: @@ -348,8 +350,8 @@ def draw_line( x_data: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): series_params = self.figure_options.series_params.get(name, {}) @@ -361,7 +363,7 @@ def draw_line( "linestyle": "-", "linewidth": 2, } - self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) + self._update_label_in_options(draw_ops, name, label, legend) draw_ops.update(**options) self._get_axis(axis).plot(x_data, y_data, **draw_ops) @@ -371,8 +373,8 @@ def draw_filled_y_area( y_ub: Sequence[float], y_lb: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): series_params = self.figure_options.series_params.get(name, {}) @@ -383,7 +385,7 @@ def draw_filled_y_area( "alpha": 0.1, "color": color, } - self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) + self._update_label_in_options(draw_ops, name, label, legend) draw_ops.update(**options) self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops) @@ -393,8 +395,8 @@ def draw_filled_x_area( x_lb: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): series_params = self.figure_options.series_params.get(name, {}) @@ -405,7 +407,7 @@ def draw_filled_x_area( "alpha": 0.1, "color": color, } - self._update_label_in_dict(draw_ops, name, legend_entry, legend_label) + self._update_label_in_options(draw_ops, name, label, legend) draw_ops.update(**options) self._get_axis(axis).fill_betweenx(y_data, x1=x_lb, x2=x_ub, **draw_ops) diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 4e74f6eab9..0f60b178c0 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -90,7 +90,7 @@ def _plot_figure(self): plotted_formatted_data = False if self.data_exists_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]): x, y, yerr = self.data_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]) - self.drawer.draw_scatter(x, y, y_err=yerr, name=ser, zorder=2, legend_entry=True) + self.drawer.draw_scatter(x, y, y_err=yerr, name=ser, zorder=2, legend=True) plotted_formatted_data = True # Scatter plot @@ -103,9 +103,10 @@ def _plot_figure(self): # markers to gray. if plotted_formatted_data: options["color"] = "gray" - # If we didn't plot formatted data, the X-Y markers should be used for the legend. + # If we didn't plot formatted data, the X-Y markers should be used for the legend. We add + # it to ``options`` so it's easier to pass to ``draw_scatter``. if not plotted_formatted_data: - options["legend_entry"] = True + options["legend"] = True self.drawer.draw_scatter( x, y, diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py index 2fa89230d7..fece828111 100644 --- a/test/visualization/mock_drawer.py +++ b/test/visualization/mock_drawer.py @@ -55,8 +55,8 @@ def draw_scatter( x_err: Optional[Sequence[float]] = None, y_err: Optional[Sequence[float]] = None, name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Does nothing.""" @@ -67,8 +67,8 @@ def draw_line( x_data: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Does nothing.""" @@ -80,8 +80,8 @@ def draw_filled_y_area( y_ub: Sequence[float], y_lb: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Does nothing.""" @@ -93,8 +93,8 @@ def draw_filled_x_area( x_lb: Sequence[float], y_data: Sequence[float], name: Optional[str] = None, - legend_entry: bool = False, - legend_label: Optional[str] = None, + label: Optional[str] = None, + legend: bool = False, **options, ): """Does nothing.""" From 6bdc6ef8dbe58f709598e653e4cbaeb218ced4a1 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 09:10:55 +0200 Subject: [PATCH 33/45] Update docstrings Co-authored-by: Daniel J. Egger <38065505+eggerdj@users.noreply.github.com> Co-authored-by: Naoki Kanazawa --- qiskit_experiments/curve_analysis/__init__.py | 4 ++-- .../composite_curve_analysis.py | 4 ++-- .../curve_analysis/curve_analysis.py | 4 ++-- qiskit_experiments/visualization/__init__.py | 1 - .../visualization/drawers/base_drawer.py | 19 ++++++++----------- .../visualization/plotters/base_plotter.py | 2 +- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/qiskit_experiments/curve_analysis/__init__.py b/qiskit_experiments/curve_analysis/__init__.py index 73253f71a6..d36bbe7f18 100644 --- a/qiskit_experiments/curve_analysis/__init__.py +++ b/qiskit_experiments/curve_analysis/__init__.py @@ -318,8 +318,8 @@ class AnalysisB(CurveAnalysis): compute custom quantities based on the raw fit parameters. See :ref:`curve_analysis_results` for details. Afterwards, the analysis draws several curves in the Matplotlib figure. -User can set custom plotter to the option ``plotter``. -The plotter defaults to the :class:`CurvePlotter`. +Users can set a custom plotter in :class:`CurveAnalysis` classes, to customize +figures, by setting the :attr:`~CurveAnalysis.plotter` attribute. Finally, it returns the list of created analysis results and Matplotlib figure. diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index 229c8dc953..3fd8fb25ee 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -352,14 +352,14 @@ def _run_analysis( params=fit_data.ufloat_params, ) y_mean = unp.nominal_values(y_data_with_uncertainty) - # Draw fit line + # Add fit line data self.plotter.set_series_data( model._name + f"_{analysis.name}", x_interp=interp_x, y_mean=y_mean, ) if fit_data.covar is not None: - # Draw confidence intervals with different n_sigma + # Add confidence interval data sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): self.plotter.set_series_data(model._name, sigmas=sigmas) diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index 97bdabbc3a..c6ef92694f 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -558,14 +558,14 @@ def _run_analysis( params=fit_data.ufloat_params, ) y_mean = unp.nominal_values(y_data_with_uncertainty) - # Draw fit line + # Add fit line data self.plotter.set_series_data( model._name, x_interp=interp_x, y_mean=y_mean, ) if fit_data.covar is not None: - # Draw confidence intervals with different n_sigma + # Add confidence interval data sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): self.plotter.set_series_data( diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index ff42fabb4d..b6754c7f89 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -54,7 +54,6 @@ BaseDrawer MplDrawer - LegacyCurveCompatDrawer Plotting Style ============== diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index f21cb256bb..916f8a65f9 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -52,8 +52,7 @@ class BaseDrawer(ABC): draw_line - This method plots a line from provided X and Y values. This method is typically called with - interpolated x and y values from a curve-fit. + This method plots a line from provided X and Y values. draw_filled_y_area @@ -70,18 +69,16 @@ class BaseDrawer(ABC): draw_text_box This method draws a text-box on the canvas, which is a rectangular region containing some text. - This method is typically called with a list of analysis results and reduced chi-squared values - from a curve-fit. Options and Figure Options ========================== Drawers have both :attr:`options` and :attr:`figure_options` available to set parameters that define - how to drawer and what is drawn. :class:`BasePlotter` is similar in that it also has ``options`` and - ``figure_options`. The former contains class-specific variables that define how an instance behaves. - The latter contains figure-specific variables that typically contain values that are drawn on the - canvas, such as text. For details on the difference between the two sets of options, see the - documentation for :class:`BasePlotter`. + how to draw and what is drawn, respectively. :class:`BasePlotter` is similar in that it also has + ``options`` and ``figure_options`. The former contains class-specific variables that define how an + instance behaves. The latter contains figure-specific variables that typically contain values that + are drawn on the canvas, such as text. For details on the difference between the two sets of options, + see the documentation for :class:`BasePlotter`. .. note:: If a drawer instance is used with a plotter, then there is the potential for any figure-option @@ -337,11 +334,11 @@ def draw_line( legend: bool = False, **options, ): - """Draw fit line. + """Draw a line. Args: x_data: X values. - y_data: Fit Y values. + y_data: Y values. name: Name of this series. label: Optional legend label to override ``name`` and ``series_params``. legend: Whether the drawn area must have a legend entry. Defaults to False. diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 9a65a6329a..1f54985f99 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -115,7 +115,7 @@ def __init__(self, drawer: BaseDrawer): """ # Data to be plotted, such as scatter points, interpolated fits, and confidence intervals self._series_data: Dict[str, Dict[str, Any]] = {} - # Data for figure-wide drawing, unrelated to series data, such as text or fit reports. + # Data that isn't directly associated with a single series, such as text or fit reports. self._figure_data: Dict[str, Any] = {} # Options for the plotter From 7c3ca90e87179676174f91a529c3c51117cc2fba Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 09:38:40 +0200 Subject: [PATCH 34/45] Refactor PlotStyle to inherit from dict --- qiskit_experiments/visualization/__init__.py | 4 +- .../visualization/drawers/base_drawer.py | 3 +- .../drawers/legacy_curve_compat_drawer.py | 2 +- .../visualization/drawers/mpl_drawer.py | 22 +-- .../visualization/plotters/base_plotter.py | 3 +- qiskit_experiments/visualization/style.py | 139 +++++++----------- test/visualization/mock_drawer.py | 2 +- test/visualization/test_style.py | 62 ++++---- 8 files changed, 108 insertions(+), 129 deletions(-) diff --git a/qiskit_experiments/visualization/__init__.py b/qiskit_experiments/visualization/__init__.py index b6754c7f89..3f35e221f8 100644 --- a/qiskit_experiments/visualization/__init__.py +++ b/qiskit_experiments/visualization/__init__.py @@ -65,8 +65,6 @@ PlotStyle """ -# PlotStyle is imported by .drawers and .plotters. Skip PlotStyle import for isort to prevent circular -# import. -from .style import PlotStyle # isort:skip from .drawers import BaseDrawer, LegacyCurveCompatDrawer, MplDrawer from .plotters import BasePlotter, CurvePlotter +from .style import PlotStyle diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 916f8a65f9..c72929ea6d 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -16,7 +16,8 @@ from typing import Dict, Optional, Sequence, Tuple from qiskit_experiments.framework import Options -from qiskit_experiments.visualization import PlotStyle + +from ..style import PlotStyle class BaseDrawer(ABC): diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index a84a186906..a1003a876c 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -175,7 +175,7 @@ def set_options(self, **fields): # PlotStyle parameters are normal options in BaseCurveDrawer. if "custom_style" in fields: custom_style = fields.pop("custom_style") - for key, value in custom_style.__dict__.items(): + for key, value in custom_style.items(): fields[key] = value self._curve_drawer.set_options(**fields) diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 1d84267933..b19dfd0f9a 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -58,7 +58,7 @@ def initialize_canvas(self): if not self.options.axis: axis = get_non_gui_ax() figure = axis.get_figure() - figure.set_size_inches(*self.style.figsize) + figure.set_size_inches(*self.style["figsize"]) else: axis = self.options.axis @@ -92,7 +92,7 @@ def initialize_canvas(self): if isinstance(label, list): # Y label can be given as a list for each sub axis label = label[i] - sub_ax.set_ylabel(label, fontsize=self.style.axis_label_size) + sub_ax.set_ylabel(label, fontsize=self.style["axis.label_size"]) if i != n_rows - 1: # remove x axis except for most-bottom plot sub_ax.set_xticklabels([]) @@ -103,18 +103,18 @@ def initialize_canvas(self): if isinstance(label, list): # X label can be given as a list for each sub axis label = label[j] - sub_ax.set_xlabel(label, fontsize=self.style.axis_label_size) + sub_ax.set_xlabel(label, fontsize=self.style["axis.label_size"]) if j == 0 or i == n_rows - 1: # Set label size for outer axes where labels are drawn - sub_ax.tick_params(labelsize=self.style.tick_label_size) + sub_ax.tick_params(labelsize=self.style["tick.label_size"]) sub_ax.grid() # Remove original axis frames axis.axis("off") else: - axis.set_xlabel(self.figure_options.xlabel, fontsize=self.style.axis_label_size) - axis.set_ylabel(self.figure_options.ylabel, fontsize=self.style.axis_label_size) - axis.tick_params(labelsize=self.style.tick_label_size) + axis.set_xlabel(self.figure_options.xlabel, fontsize=self.style["axis.label_size"]) + axis.set_ylabel(self.figure_options.ylabel, fontsize=self.style["axis.label_size"]) + axis.tick_params(labelsize=self.style["tick.label_size"]) axis.grid() self._axis = axis @@ -130,7 +130,7 @@ def format_canvas(self): for sub_ax in all_axes: _, labels = sub_ax.get_legend_handles_labels() if len(labels) > 1: - sub_ax.legend(loc=self.style.legend_loc) + sub_ax.legend(loc=self.style["legend.loc"]) # Format x and y axis for ax_type in ("x", "y"): @@ -216,7 +216,7 @@ def format_canvas(self): if self.figure_options.figure_title is not None: self._axis.set_title( label=self.figure_options.figure_title, - fontsize=self.style.axis_label_size, + fontsize=self.style["axis.label_size"], ) def _get_axis(self, index: Optional[int] = None) -> Axes: @@ -427,14 +427,14 @@ def draw_text_box( bbox_props.update(**options) if rel_pos is None: - rel_pos = self.style.text_box_rel_pos + rel_pos = self.style["textbox.rel_pos"] text_box_handler = self._axis.text( *rel_pos, s=description, ha="center", va="top", - size=self.style.text_box_text_size, + size=self.style["textbox.text_size"], transform=self._axis.transAxes, zorder=1000, # Very large zorder to draw over other graphics. ) diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 1f54985f99..42294cc526 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -17,7 +17,8 @@ from qiskit_experiments.framework import Options from qiskit_experiments.visualization.drawers import BaseDrawer -from qiskit_experiments.visualization.style import PlotStyle + +from ..style import PlotStyle class BasePlotter(ABC): diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index 172478e9b6..7e877eabed 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -12,111 +12,80 @@ """ Configurable stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. """ -from copy import copy -from typing import Dict, Tuple -from qiskit_experiments.framework import Options - -class PlotStyle(Options): +class PlotStyle(dict): """A stylesheet for :class:`BasePlotter` and :class:`BaseDrawer`. This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`. The default style for - Qiskit Experiments is defined in :meth:`default_style`. :class:`PlotStyle` subclasses - :class:`Options` and has a similar interface. Extra helper methods are included to merge and update - instances of :class:`PlotStyle`: :meth:`merge` and :meth:`update` respectively. + Qiskit Experiments is defined in :meth:`default_style`. Style parameters are stored as dictionary + entries, grouped by graphics or figure component. For example, style parameters relating to textboxes + have the prefix ``textbox.``. For default style parameter names and their values, see the + :meth:`default_style` method. + + Example: + .. code-block:: python + # Create custom style + custom_style = PlotStyle( + { + "legend.loc": "upper right", + "textbox.rel_pos": (1, 1), + "textbox.text_size": 14, + } + ) + + # Create full style, using PEP448 to combine with default style. + full_style = PlotStyle.merge(PlotStyle.default_style(), custom_style) + + # Query style parameters + full_style["legend.loc"] # Returns "upper right" + full_style["axis.label_size"] # Returns the value provided in ``PlotStyle.default_style()`` """ @classmethod def default_style(cls) -> "PlotStyle": """The default style across Qiskit Experiments. + Style Parameters: + figsize (Tuple[int,int]): The size of the figure ``(width, height)``, in inches. + legend.loc (str): The location of the legend. + tick.label_size (int): The font size for tick labels. + axis.label_size (int): The font size for axis labels. + textbox.rel_pos (Tuple[float,float]): The relative position ``(horizontal, vertical)`` of + textboxes, as a percentage of the canvas dimensions. + textbox.text_size (int): The font size for textboxes. + Returns: PlotStyle: The default plot style used by Qiskit Experiments. """ - # pylint: disable = attribute-defined-outside-init - # We disable attribute-defined-outside-init so we can set style parameters outside of the - # initialization call and thus include type hints. - new = cls() - # size of figure (width, height) - new.figsize: Tuple[int, int] = (8, 5) - - # legent location (vertical, horizontal) - new.legend_loc: str = "center right" - - # size of tick label - new.tick_label_size: int = 14 - - # size of axis label - new.axis_label_size: int = 16 - - # relative position of a text - new.text_box_rel_pos: Tuple[float, float] = (0.6, 0.95) - - # size of fit report text - new.text_box_text_size: int = 14 - - return new - - def update(self, other_style: "PlotStyle"): - """Updates the plot styles fields with those set in ``other_style``. - - Args: - other_style: The style with new field values. - """ - self.update_options(**other_style._fields) + style = { + # size of figure (width, height) + "figsize": (8, 5), # Tuple[int, int] + # legend location (vertical, horizontal) + "legend.loc": "center right", # str + # size of tick label + "tick.label_size": 14, # int + # size of axis label + "axis.label_size": 16, # int + # relative position of a textbox + "textbox.rel_pos": (0.6, 0.95), # Tuple[float, float] + # size of textbox text + "textbox.text_size": 14, # int + } + return cls(**style) @classmethod def merge(cls, style1: "PlotStyle", style2: "PlotStyle") -> "PlotStyle": - """Merge two PlotStyle instances into a new instance. + """Merge ``style2`` into ``style1`` as a new PlotStyle instance. - The styles are merged such that style fields in ``style2`` have priority. i.e., a field ``foo``, - defined in both input styles, will have the value :code-block:`style2.foo` in the output. + This method merges an additional style ``style2`` into a base instance ``style1``, returning the + merged style instance instead of modifying the inputs. Args: - style1: The first style. - style2: The second style. - - Returns: - PlotStyle: A plot style containing the combined fields of both input styles. - - Raises: - RuntimeError: If either of the input styles is not of type :class:`PlotStyle`. - """ - if not isinstance(style1, PlotStyle) or not isinstance(style2, PlotStyle): - raise RuntimeError( - "Incorrect style type for PlotStyle.merge: expected PlotStyle but got " - f"{type(style1).__name__} and {type(style2).__name__}" - ) - new_style = copy(style1) - new_style.update(style2) - return new_style - - def config(self) -> Dict: - """Return the config dictionary for this PlotStyle instance. - - .. Note:: - Validators are not currently supported + style1: Base PlotStyle instance. + style2: Additional PlotStyle instance. Returns: - dict: A dictionary containing the config of the plot style. + PlotStyle: merged style instance. """ - return { - "cls": type(self), - **self._fields, - } - - @property - def __dict__(self) -> Dict: - # Needed as __dict__ is not inherited by subclasses. - return super().__dict__ - - def __json_encode__(self): - return self.config() - - @classmethod - def __json_decode__(cls, value): - kwargs = value - kwargs.pop("cls") - inst = cls(**kwargs) - return inst + return PlotStyle({**style1, **style2}) diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py index fece828111..34dd194889 100644 --- a/test/visualization/mock_drawer.py +++ b/test/visualization/mock_drawer.py @@ -37,7 +37,7 @@ def _default_style(cls) -> PlotStyle: overwrite_param: A test style parameter to be overwritten by a test. """ style = super()._default_style() - style.overwrite_param = "overwrite_param" + style["overwrite_param"] = "overwrite_param" return style def initialize_canvas(self): diff --git a/test/visualization/test_style.py b/test/visualization/test_style.py index d9f5c513de..9d3f5ea9e8 100644 --- a/test/visualization/test_style.py +++ b/test/visualization/test_style.py @@ -49,19 +49,29 @@ def _dummy_styles(cls) -> Tuple[PlotStyle, PlotStyle, PlotStyle, PlotStyle]: ) return custom_1, custom_2, expected_12, expected_21 - def test_default_contains_necessary_fields(self): - """Test that expected fields are set in the default style.""" + def test_default_only_contains_expected_fields(self): + """Test that only expected fields are set in the default style. + + This enforces two things: + 1. The expected style fields are not None. + 2. No extra fields are set. + + The second property being enforced is to make sure that this test fails if new default style + parameters are added to :meth:`PlotStyle.default_style` but not to this test. + """ default = PlotStyle.default_style() expected_not_none_fields = [ "figsize", - "legend_loc", - "tick_label_size", - "axis_label_size", - "text_box_rel_pos", - "text_box_text_size", + "legend.loc", + "tick.label_size", + "axis.label_size", + "textbox.rel_pos", + "textbox.text_size", ] for field in expected_not_none_fields: - self.assertIsNotNone(getattr(default, field)) + self.assertIsNotNone(default.get(field, None)) + # Check that default style keys are as expected, ignoring order. + self.assertCountEqual(expected_not_none_fields, list(default.keys())) def test_update(self): """Test that styles can be updated.""" @@ -69,19 +79,19 @@ def test_update(self): # copy(...) is needed as .update() modifies the style instance actual_12 = copy(custom_1) - actual_12.update(custom_2) + actual_12.update(**custom_2) actual_21 = copy(custom_2) - actual_21.update(custom_1) + actual_21.update(**custom_1) - self.assertEqual(actual_12, expected_12) - self.assertEqual(actual_21, expected_21) + self.assertDictEqual(actual_12, expected_12) + self.assertDictEqual(actual_21, expected_21) - def test_merge(self): + def test_merge_in_init(self): """Test that styles can be merged.""" custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() - self.assertEqual(PlotStyle.merge(custom_1, custom_2), expected_12) - self.assertEqual(PlotStyle.merge(custom_2, custom_1), expected_21) + self.assertDictEqual(PlotStyle.merge(custom_1, custom_2), expected_12) + self.assertDictEqual(PlotStyle.merge(custom_2, custom_1), expected_21) def test_field_access(self): """Test that fields are accessed correctly""" @@ -90,16 +100,16 @@ def test_field_access(self): # y isn't assigned and therefore doesn't exist in dummy_style ) - self.assertEqual(dummy_style.x, "x") + self.assertEqual(dummy_style["x"], "x") # This should throw as we haven't assigned y - with self.assertRaises(AttributeError): + with self.assertRaises(KeyError): # Disable pointless-statement as accessing style fields can raise an exception. # pylint: disable = pointless-statement - dummy_style.y + dummy_style["y"] def test_dict(self): - """Test that PlotStyle can be converted into a dictionary.""" + """Test that PlotStyle can be treated as a dictionary.""" dummy_style = PlotStyle( a="a", b=0, @@ -110,13 +120,13 @@ def test_dict(self): "b": 0, "c": [1, 2, 3], } - actual_dict = dummy_style.__dict__ + actual_dict = dict(dummy_style) self.assertDictEqual(actual_dict, expected_dict, msg="PlotStyle dict is not as expected.") # Add a new variable - dummy_style.new_variable = 5e9 + dummy_style["new_variable"] = 5e9 expected_dict["new_variable"] = 5e9 - actual_dict = dummy_style.__dict__ + actual_dict = dict(dummy_style) self.assertDictEqual( actual_dict, expected_dict, @@ -133,12 +143,12 @@ def test_update_dict(self): actual_21 = copy(custom_2) actual_21.update(custom_1) - self.assertDictEqual(actual_12.__dict__, expected_12.__dict__) - self.assertDictEqual(actual_21.__dict__, expected_21.__dict__) + self.assertDictEqual(actual_12, expected_12) + self.assertDictEqual(actual_21, expected_21) def test_merge_dict(self): """Test that PlotStyle dictionary is correct when merged.""" custom_1, custom_2, expected_12, expected_21 = self._dummy_styles() - self.assertDictEqual(PlotStyle.merge(custom_1, custom_2).__dict__, expected_12.__dict__) - self.assertDictEqual(PlotStyle.merge(custom_2, custom_1).__dict__, expected_21.__dict__) + self.assertDictEqual(PlotStyle.merge(custom_1, custom_2), expected_12) + self.assertDictEqual(PlotStyle.merge(custom_2, custom_1), expected_21) From 8ed2c9fb71ef57151f80f94ad82a9aaaf44097d2 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 11:28:33 +0200 Subject: [PATCH 35/45] Revert namespace renaming of PlotStyle parameters --- .../analysis/cr_hamiltonian_analysis.py | 8 +++-- .../visualization/drawers/mpl_drawer.py | 20 ++++++------ qiskit_experiments/visualization/style.py | 32 +++++++++---------- test/visualization/test_style.py | 10 +++--- 4 files changed, 36 insertions(+), 34 deletions(-) diff --git a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py index fa060a70df..62831f3a8e 100644 --- a/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/cr_hamiltonian_analysis.py @@ -64,9 +64,11 @@ def _default_options(cls): default_options.plotter.set_options( subplots=(3, 1), style=PlotStyle( - figsize=(8, 10), - legend_loc="lower right", - text_box_rel_pos=(0.28, -0.10), + { + "figsize": (8, 10), + "legend_loc": "lower right", + "textbox_rel_pos": (0.28, -0.10), + } ), ) default_options.plotter.set_figure_options( diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index b19dfd0f9a..d16c6d57d8 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -92,7 +92,7 @@ def initialize_canvas(self): if isinstance(label, list): # Y label can be given as a list for each sub axis label = label[i] - sub_ax.set_ylabel(label, fontsize=self.style["axis.label_size"]) + sub_ax.set_ylabel(label, fontsize=self.style["axis_label_size"]) if i != n_rows - 1: # remove x axis except for most-bottom plot sub_ax.set_xticklabels([]) @@ -103,18 +103,18 @@ def initialize_canvas(self): if isinstance(label, list): # X label can be given as a list for each sub axis label = label[j] - sub_ax.set_xlabel(label, fontsize=self.style["axis.label_size"]) + sub_ax.set_xlabel(label, fontsize=self.style["axis_label_size"]) if j == 0 or i == n_rows - 1: # Set label size for outer axes where labels are drawn - sub_ax.tick_params(labelsize=self.style["tick.label_size"]) + sub_ax.tick_params(labelsize=self.style["tick_label_size"]) sub_ax.grid() # Remove original axis frames axis.axis("off") else: - axis.set_xlabel(self.figure_options.xlabel, fontsize=self.style["axis.label_size"]) - axis.set_ylabel(self.figure_options.ylabel, fontsize=self.style["axis.label_size"]) - axis.tick_params(labelsize=self.style["tick.label_size"]) + axis.set_xlabel(self.figure_options.xlabel, fontsize=self.style["axis_label_size"]) + axis.set_ylabel(self.figure_options.ylabel, fontsize=self.style["axis_label_size"]) + axis.tick_params(labelsize=self.style["tick_label_size"]) axis.grid() self._axis = axis @@ -130,7 +130,7 @@ def format_canvas(self): for sub_ax in all_axes: _, labels = sub_ax.get_legend_handles_labels() if len(labels) > 1: - sub_ax.legend(loc=self.style["legend.loc"]) + sub_ax.legend(loc=self.style["legend_loc"]) # Format x and y axis for ax_type in ("x", "y"): @@ -216,7 +216,7 @@ def format_canvas(self): if self.figure_options.figure_title is not None: self._axis.set_title( label=self.figure_options.figure_title, - fontsize=self.style["axis.label_size"], + fontsize=self.style["axis_label_size"], ) def _get_axis(self, index: Optional[int] = None) -> Axes: @@ -427,14 +427,14 @@ def draw_text_box( bbox_props.update(**options) if rel_pos is None: - rel_pos = self.style["textbox.rel_pos"] + rel_pos = self.style["textbox_rel_pos"] text_box_handler = self._axis.text( *rel_pos, s=description, ha="center", va="top", - size=self.style["textbox.text_size"], + size=self.style["textbox_text_size"], transform=self._axis.transAxes, zorder=1000, # Very large zorder to draw over other graphics. ) diff --git a/qiskit_experiments/visualization/style.py b/qiskit_experiments/visualization/style.py index 7e877eabed..1a0a4bc077 100644 --- a/qiskit_experiments/visualization/style.py +++ b/qiskit_experiments/visualization/style.py @@ -20,7 +20,7 @@ class PlotStyle(dict): This style class is used by :class:`BasePlotter` and :class:`BaseDrawer`. The default style for Qiskit Experiments is defined in :meth:`default_style`. Style parameters are stored as dictionary entries, grouped by graphics or figure component. For example, style parameters relating to textboxes - have the prefix ``textbox.``. For default style parameter names and their values, see the + have the prefix ``textbox_``. For default style parameter names and their values, see the :meth:`default_style` method. Example: @@ -28,9 +28,9 @@ class PlotStyle(dict): # Create custom style custom_style = PlotStyle( { - "legend.loc": "upper right", - "textbox.rel_pos": (1, 1), - "textbox.text_size": 14, + "legend_loc": "upper right", + "textbox_rel_pos": (1, 1), + "textbox_text_size": 14, } ) @@ -38,8 +38,8 @@ class PlotStyle(dict): full_style = PlotStyle.merge(PlotStyle.default_style(), custom_style) # Query style parameters - full_style["legend.loc"] # Returns "upper right" - full_style["axis.label_size"] # Returns the value provided in ``PlotStyle.default_style()`` + full_style["legend_loc"] # Returns "upper right" + full_style["axis_label_size"] # Returns the value provided in ``PlotStyle.default_style()`` """ @classmethod @@ -48,12 +48,12 @@ def default_style(cls) -> "PlotStyle": Style Parameters: figsize (Tuple[int,int]): The size of the figure ``(width, height)``, in inches. - legend.loc (str): The location of the legend. - tick.label_size (int): The font size for tick labels. - axis.label_size (int): The font size for axis labels. - textbox.rel_pos (Tuple[float,float]): The relative position ``(horizontal, vertical)`` of + legend_loc (str): The location of the legend. + tick_label_size (int): The font size for tick labels. + axis_label_size (int): The font size for axis labels. + textbox_rel_pos (Tuple[float,float]): The relative position ``(horizontal, vertical)`` of textboxes, as a percentage of the canvas dimensions. - textbox.text_size (int): The font size for textboxes. + textbox_text_size (int): The font size for textboxes. Returns: PlotStyle: The default plot style used by Qiskit Experiments. @@ -62,15 +62,15 @@ def default_style(cls) -> "PlotStyle": # size of figure (width, height) "figsize": (8, 5), # Tuple[int, int] # legend location (vertical, horizontal) - "legend.loc": "center right", # str + "legend_loc": "center right", # str # size of tick label - "tick.label_size": 14, # int + "tick_label_size": 14, # int # size of axis label - "axis.label_size": 16, # int + "axis_label_size": 16, # int # relative position of a textbox - "textbox.rel_pos": (0.6, 0.95), # Tuple[float, float] + "textbox_rel_pos": (0.6, 0.95), # Tuple[float, float] # size of textbox text - "textbox.text_size": 14, # int + "textbox_text_size": 14, # int } return cls(**style) diff --git a/test/visualization/test_style.py b/test/visualization/test_style.py index 9d3f5ea9e8..a52bcfd82e 100644 --- a/test/visualization/test_style.py +++ b/test/visualization/test_style.py @@ -62,11 +62,11 @@ def test_default_only_contains_expected_fields(self): default = PlotStyle.default_style() expected_not_none_fields = [ "figsize", - "legend.loc", - "tick.label_size", - "axis.label_size", - "textbox.rel_pos", - "textbox.text_size", + "legend_loc", + "tick_label_size", + "axis_label_size", + "textbox_rel_pos", + "textbox_text_size", ] for field in expected_not_none_fields: self.assertIsNotNone(default.get(field, None)) From f1d1fc2717fd3cf425561c6915840f4d15f9c34f Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 11:28:58 +0200 Subject: [PATCH 36/45] Rename figure_data to supplementary_data --- .../composite_curve_analysis.py | 2 +- .../curve_analysis/curve_analysis.py | 2 +- .../visualization/plotters/base_plotter.py | 61 ++++++++++--------- .../visualization/plotters/curve_plotter.py | 6 +- test/visualization/mock_plotter.py | 2 +- test/visualization/test_plotter.py | 10 +-- 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index 3fd8fb25ee..0d5fd9b3fa 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -391,7 +391,7 @@ def _run_analysis( for group, fit_data in fit_dataset.items(): chisqs.append(r"reduced-$\chi^2$ = " + f"{fit_data.reduced_chisq: .4g} ({group})") report += "\n".join(chisqs) - self.plotter.set_figure_data(report_text=report) + self.plotter.set_supplementary_data(report_text=report) return analysis_results, [self.plotter.figure()] diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index c6ef92694f..d9b404099b 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -579,7 +579,7 @@ def _run_analysis( if isinstance(res.value, (float, UFloat)): report_description += f"{analysis_result_to_repr(res)}\n" report_description += r"reduced-$\chi^2$ = " + f"{fit_data.reduced_chisq: .4g}" - self.plotter.set_figure_data(report_text=report_description) + self.plotter.set_supplementary_data(report_text=report_description) # Add raw data points if self.options.return_data_points: diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index 42294cc526..f54fa0a545 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -27,20 +27,20 @@ class BasePlotter(ABC): A plotter takes data from an experiment analysis class or experiment and plots a given figure using a drawing backend. Sub-classes define the kind of figure created and the expected data. - Data is split into series and figure data. Series data is grouped by series name (str). For + Data is split into series and supplementary data. Series data is grouped by series name (str). For :class:`CurveAnalysis`, this is the model name for a curve fit. For series data associated with a - single series name and figure data, data-values are identified by a data-key (str). Different data - per series and figure must have a different data-key to avoid overwriting values. Experiment and + single series name and supplementary data, data-values are identified by a data-key (str). Different + data per series and figure must have a different data-key to avoid overwriting values. Experiment and analysis results can be passed to the plotter so appropriate graphics can be drawn on the figure - canvas. Series data is added to the plotter using :meth:`set_series_data` whereas figure data is - added using :meth:`set_figure_data`. Series and figure data are retrieved using :meth:`data_for` and - :attr:`figure_data` respectively. + canvas. Series data is added to the plotter using :meth:`set_series_data` whereas supplementary data + is added using :meth:`set_supplementary_data`. Series and supplementary data are retrieved using + :meth:`data_for` and :attr:`supplementary_data` respectively. Series data contains values to be plotted on a canvas, such that the data can be grouped into subsets identified by their series name. Series names can be thought of as legend labels for the plotted - data, and as curve names for a curve-fit. Figure data is not associated with a series or curve and is - instead only associated with the figure. Examples include analysis reports or other text that is - drawn onto the figure canvas. + data, and as curve names for a curve-fit. Supplementary data is not associated with a series or curve + and is instead only associated with the figure. Examples include analysis reports or other text that + is drawn onto the figure canvas. Options and Figure Options ========================== @@ -117,7 +117,7 @@ def __init__(self, drawer: BaseDrawer): # Data to be plotted, such as scatter points, interpolated fits, and confidence intervals self._series_data: Dict[str, Dict[str, Any]] = {} # Data that isn't directly associated with a single series, such as text or fit reports. - self._figure_data: Dict[str, Any] = {} + self._supplementary_data: Dict[str, Any] = {} # Options for the plotter self._options = self._default_options() @@ -133,13 +133,14 @@ def __init__(self, drawer: BaseDrawer): self.drawer = drawer @property - def figure_data(self) -> Dict[str, Any]: - """Data for the figure being plotted. + def supplementary_data(self) -> Dict[str, Any]: + """Additional data for the figure being plotted, that isn't associated with a series. - Figure data includes text, fit reports, or other data that is associated with the figure as a - whole and not an individual series. + Supplementary data includes text, fit reports, or other data that is associated with the figure + but not an individual series. It is typically data additional to the direct results of an + experiment. """ - return self._figure_data + return self._supplementary_data @property def series_data(self) -> Dict[str, Dict[str, Any]]: @@ -248,22 +249,24 @@ def clear_series_data(self, series_name: Optional[str] = None): elif series_name in self._series_data: self._series_data.pop(series_name) - def set_figure_data(self, **data_kwargs): - """Sets data for the entire figure. + def set_supplementary_data(self, **data_kwargs): + """Sets supplementary data for the plotter. - Figure data differs from series data in that it is not associate with a series name. Fit reports - are examples of figure data as they are drawn on figures to report on analysis results and the - "goodness" of a curve-fit, not a specific line, point, or shape drawn on the figure canvas. + Supplementary data differs from series data in that it is not associate with a series name. Fit + reports are examples of supplementary data as they contain fit results from an analysis class, + such as the "goodness" of a curve-fit. Note that if data has already been assigned for the given data-key, it will be overwritten with - the new values. ``set_figure_data`` will warn if the data-key is unexpected; i.e., not within - those returned by :meth:`expected_figure_data_keys`. + the new values. ``set_supplementary_data`` will warn if the data-key is unexpected; i.e., not + within those returned by :meth:`expected_supplementary_data_keys`. """ # Warn if any data-keys are not expected. unknown_data_keys = [ - data_key for data_key in data_kwargs if data_key not in self.expected_figure_data_keys() + data_key + for data_key in data_kwargs + if data_key not in self.expected_supplementary_data_keys() ] for unknown_data_key in unknown_data_keys: warnings.warn( @@ -271,11 +274,11 @@ def set_figure_data(self, **data_kwargs): "not be used by the plotter class." ) - self._figure_data.update(**data_kwargs) + self._supplementary_data.update(**data_kwargs) - def clear_figure_data(self): - """Clears figure data.""" - self._figure_data = {} + def clear_supplementary_data(self): + """Clears supplementary data.""" + self._supplementary_data = {} def data_exists_for(self, series_name: str, data_keys: Union[str, List[str]]) -> bool: """Returns whether the given data-keys exist for the given series. @@ -337,8 +340,8 @@ def expected_series_data_keys(cls) -> List[str]: @classmethod @abstractmethod - def expected_figure_data_keys(cls) -> List[str]: - """Returns the expected figures data-keys supported by this plotter.""" + def expected_supplementary_data_keys(cls) -> List[str]: + """Returns the expected supplementary data-keys supported by this plotter.""" @property def options(self) -> Options: diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 0f60b178c0..0b43b51931 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -55,7 +55,7 @@ def expected_series_data_keys(cls) -> List[str]: ] @classmethod - def expected_figure_data_keys(cls) -> List[str]: + def expected_supplementary_data_keys(cls) -> List[str]: """Returns the expected figures data-keys supported by this plotter. Data Keys: @@ -133,6 +133,6 @@ def _plot_figure(self): ) # Fit report - if "report_text" in self.figure_data: - report_text = self.figure_data["report_text"] + if "report_text" in self.supplementary_data: + report_text = self.supplementary_data["report_text"] self.drawer.draw_text_box(report_text) diff --git a/test/visualization/mock_plotter.py b/test/visualization/mock_plotter.py index c31abea64f..402df6787b 100644 --- a/test/visualization/mock_plotter.py +++ b/test/visualization/mock_plotter.py @@ -67,5 +67,5 @@ def expected_series_data_keys(cls) -> List[str]: return ["x", "y", "z"] @classmethod - def expected_figure_data_keys(cls) -> List[str]: + def expected_supplementary_data_keys(cls) -> List[str]: return [] diff --git a/test/visualization/test_plotter.py b/test/visualization/test_plotter.py index db6aee1090..e8cd29baf9 100644 --- a/test/visualization/test_plotter.py +++ b/test/visualization/test_plotter.py @@ -65,20 +65,20 @@ def test_series_data_end_to_end(self): # Must index [0] for `data_for` as it returns a tuple. self.assertEqual(value, plotter.data_for(series, data_key)[0]) - def test_figure_data_end_to_end(self): + def test_supplementary_data_end_to_end(self): """Test end-to-end for figure data setting and retrieval.""" plotter = MockPlotter(MockDrawer()) - expected_figure_data = { + expected_supplementary_data = { "report_text": "Lorem ipsum", "another_data_key": 3e9, } - plotter.set_figure_data(**expected_figure_data) + plotter.set_supplementary_data(**expected_supplementary_data) # Check if figure data has been stored and can be retrieved - for key, expected_value in expected_figure_data.items(): - actual_value = plotter.figure_data[key] + for key, expected_value in expected_supplementary_data.items(): + actual_value = plotter.supplementary_data[key] self.assertEqual( expected_value, actual_value, From 9a909f5d631903e67cfd4aa95bce9e3db0cdd222 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 11:51:28 +0200 Subject: [PATCH 37/45] Rename CurvePlotter data-keys to be more consistent --- .../composite_curve_analysis.py | 15 ++++++++------ .../curve_analysis/curve_analysis.py | 10 +++++----- .../visualization/plotters/curve_plotter.py | 20 +++++++++---------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index 0d5fd9b3fa..d80d7bedf4 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -342,27 +342,30 @@ def _run_analysis( # Draw fit result if self.options.plot: - interp_x = np.linspace( + x_interp = np.linspace( np.min(formatted_data.x), np.max(formatted_data.x), num=100 ) for model in analysis.models: y_data_with_uncertainty = eval_with_uncertainties( - x=interp_x, + x=x_interp, model=model, params=fit_data.ufloat_params, ) - y_mean = unp.nominal_values(y_data_with_uncertainty) + y_interp = unp.nominal_values(y_data_with_uncertainty) # Add fit line data self.plotter.set_series_data( model._name + f"_{analysis.name}", - x_interp=interp_x, - y_mean=y_mean, + x_interp=x_interp, + y_interp=y_interp, ) if fit_data.covar is not None: # Add confidence interval data sigmas = unp.std_devs(y_data_with_uncertainty) if np.isfinite(sigmas).all(): - self.plotter.set_series_data(model._name, sigmas=sigmas) + self.plotter.set_series_data( + model._name + f"_{analysis.name}", + sigmas=sigmas, + ) # Add raw data points if self.options.return_data_points: diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index d9b404099b..f35adeb5cf 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -550,19 +550,19 @@ def _run_analysis( # This is the case when fit model exist but no data to fit is provided. # For example, experiment may omit experimenting with some setting. continue - interp_x = np.linspace(np.min(sub_data.x), np.max(sub_data.x), num=100) + x_interp = np.linspace(np.min(sub_data.x), np.max(sub_data.x), num=100) y_data_with_uncertainty = eval_with_uncertainties( - x=interp_x, + x=x_interp, model=model, params=fit_data.ufloat_params, ) - y_mean = unp.nominal_values(y_data_with_uncertainty) + y_interp = unp.nominal_values(y_data_with_uncertainty) # Add fit line data self.plotter.set_series_data( model._name, - x_interp=interp_x, - y_mean=y_mean, + x_interp=x_interp, + y_interp=y_interp, ) if fit_data.covar is not None: # Add confidence interval data diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 0b43b51931..860ec396dd 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -39,8 +39,8 @@ def expected_series_data_keys(cls) -> List[str]: y_formatted: Y-values for processed results. Goes with ``x_formatted``. y_formatted_err: Error in ``y_formatted``, to be plotted as error-bars. x_interp: Interpolated X-values for a curve-fit. - y_mean: Y-values corresponding to the fit for ``x_interp`` X-values. - sigmas: The standard-deviations of the fit for each X-value in ``x_interp``. + y_interp: Y-values corresponding to the fit for ``y_interp`` X-values. + y_interp_err: The standard-deviations of the fit for each X-value in ``y_interp``. This data-key relates to the option ``plot_sigma``. """ return [ @@ -50,8 +50,8 @@ def expected_series_data_keys(cls) -> List[str]: "y_formatted", "y_formatted_err", "x_interp", - "y_mean", - "sigmas", + "y_interp", + "y_interp_err", ] @classmethod @@ -115,18 +115,18 @@ def _plot_figure(self): ) # Line plot for fit - if self.data_exists_for(ser, ["x_interp", "y_mean"]): - x, y = self.data_for(ser, ["x_interp", "y_mean"]) + if self.data_exists_for(ser, ["x_interp", "y_interp"]): + x, y = self.data_for(ser, ["x_interp", "y_interp"]) self.drawer.draw_line(x, y, name=ser, zorder=3) # Confidence interval plot - if self.data_exists_for(ser, ["x_interp", "y_mean", "sigmas"]): - x, y_mean, sigmas = self.data_for(ser, ["x_interp", "y_mean", "sigmas"]) + if self.data_exists_for(ser, ["x_interp", "y_interp", "y_interp_err"]): + x, y_interp, y_interp_err = self.data_for(ser, ["x_interp", "y_interp", "y_interp_err"]) for n_sigma, alpha in self.options.plot_sigma: self.drawer.draw_filled_y_area( x, - y_mean + n_sigma * sigmas, - y_mean - n_sigma * sigmas, + y_interp + n_sigma * y_interp_err, + y_interp - n_sigma * y_interp_err, name=ser, alpha=alpha, zorder=5, From fdf1c23669d78bdbe39e1b2b38f8910162af270e Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 11:54:15 +0200 Subject: [PATCH 38/45] Rename CurvePlotter data-keys to be more consistent, follow-up --- .../curve_analysis/composite_curve_analysis.py | 6 +++--- qiskit_experiments/curve_analysis/curve_analysis.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/qiskit_experiments/curve_analysis/composite_curve_analysis.py b/qiskit_experiments/curve_analysis/composite_curve_analysis.py index d80d7bedf4..3726f3e51c 100644 --- a/qiskit_experiments/curve_analysis/composite_curve_analysis.py +++ b/qiskit_experiments/curve_analysis/composite_curve_analysis.py @@ -360,11 +360,11 @@ def _run_analysis( ) if fit_data.covar is not None: # Add confidence interval data - sigmas = unp.std_devs(y_data_with_uncertainty) - if np.isfinite(sigmas).all(): + y_interp_err = unp.std_devs(y_data_with_uncertainty) + if np.isfinite(y_interp_err).all(): self.plotter.set_series_data( model._name + f"_{analysis.name}", - sigmas=sigmas, + y_interp_err=y_interp_err, ) # Add raw data points diff --git a/qiskit_experiments/curve_analysis/curve_analysis.py b/qiskit_experiments/curve_analysis/curve_analysis.py index f35adeb5cf..6c3022c12a 100644 --- a/qiskit_experiments/curve_analysis/curve_analysis.py +++ b/qiskit_experiments/curve_analysis/curve_analysis.py @@ -566,11 +566,11 @@ def _run_analysis( ) if fit_data.covar is not None: # Add confidence interval data - sigmas = unp.std_devs(y_data_with_uncertainty) - if np.isfinite(sigmas).all(): + y_interp_err = unp.std_devs(y_data_with_uncertainty) + if np.isfinite(y_interp_err).all(): self.plotter.set_series_data( model._name, - sigmas=sigmas, + y_interp_err=y_interp_err, ) # Write fitting report From 84ef5732ea3008470730696265d4d2417b0f932f Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 11:54:50 +0200 Subject: [PATCH 39/45] Refactor visualization classes to have clearer function names and docstrings --- .../visualization/drawers/mpl_drawer.py | 15 +++++++++++++-- .../visualization/plotters/base_plotter.py | 10 +++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index d16c6d57d8..c697ac1bf0 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -12,7 +12,7 @@ """Curve drawer for matplotlib backend.""" -from typing import Dict, Optional, Sequence, Tuple +from typing import Any, Dict, Optional, Sequence, Tuple import numpy as np from matplotlib.axes import Axes @@ -42,9 +42,20 @@ class PrefixFormatter(Formatter): """ def __init__(self, factor: float): + """Create a PrefixFormatter instance. + + Args: + factor: factor by which to scale tick values. + """ self.factor = factor - def __call__(self, x, pos=None): + def __call__(self, x: Any, pos: int = None): + """Returns the formatted string for tick position ``pos`` and value ``x``. + + Args: + x: the tick value to format. + pos: the tick label position. + """ return self.fix_minus("{:.3g}".format(x * self.factor)) def __init__(self): diff --git a/qiskit_experiments/visualization/plotters/base_plotter.py b/qiskit_experiments/visualization/plotters/base_plotter.py index f54fa0a545..d7375995a9 100644 --- a/qiskit_experiments/visualization/plotters/base_plotter.py +++ b/qiskit_experiments/visualization/plotters/base_plotter.py @@ -309,17 +309,17 @@ def _plot_figure(self): """ def figure(self) -> Any: - """Generates and returns a figure for the already provided series and figure data. + """Generates and returns a figure for the already provided series and supplementary data. :meth:`figure` calls :meth:`_plot_figure`, which is overridden by sub-classes. Before and after - calling :meth:`_plot_figure`; :func:`_initialize_drawer`, :func:`initialize_canvas` and + calling :meth:`_plot_figure`; :func:`_configure_drawer`, :func:`initialize_canvas` and :func:`format_canvas` are called on the drawer respectively. Returns: Any: A figure generated by :attr:`drawer`, of the same type as ``drawer.figure``. """ # Initialize drawer, to copy axis, subplots, style, and figure-options across. - self._initialize_drawer() + self._configure_drawer() # Initialize canvas, which creates subplots, assigns axis labels, etc. self.drawer.initialize_canvas() @@ -452,7 +452,7 @@ def set_figure_options(self, **fields): self._figure_options.update_options(**fields) self._set_figure_options = self._set_figure_options.union(fields) - def _initialize_drawer(self): + def _configure_drawer(self): """Configures :attr:`drawer` before plotting. The following actions are taken: @@ -463,7 +463,7 @@ def _initialize_drawer(self): These steps are different as all figure-options could be passed to :attr:`drawer`, if the drawer already has a figure-option with the same name. ``axis``, ``subplots``, and ``style`` are the only plotter options (from :attr:`options`) passed to :attr:`drawer` in - :meth:`_initialize_drawer`. This is done as these options make more sense as an option for a + :meth:`_configure_drawer`. This is done as these options make more sense as an option for a plotter, given the interface of :class:`BasePlotter`. """ ## Axis, subplots, and style From 42f949c9cfe1fefbebe47dc81ae70ff6b29b8d2c Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 12:03:09 +0200 Subject: [PATCH 40/45] Fix deprecation warning with Matplotlib 3.6.0 --- qiskit_experiments/visualization/drawers/mpl_drawer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index c697ac1bf0..85227d733c 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -30,7 +30,7 @@ class MplDrawer(BaseDrawer): """Drawer for MatplotLib backend.""" - DefaultMarkers = MarkerStyle().filled_markers + DefaultMarkers = MarkerStyle.filled_markers DefaultColors = tab10.colors class PrefixFormatter(Formatter): From 37289698d648e6b2647ba1f8a60adfbfd218b28d Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 12:05:37 +0200 Subject: [PATCH 41/45] Fix lint --- qiskit_experiments/visualization/drawers/mpl_drawer.py | 5 ++++- qiskit_experiments/visualization/plotters/curve_plotter.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index 85227d733c..bf8d2d41f2 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -49,12 +49,15 @@ def __init__(self, factor: float): """ self.factor = factor - def __call__(self, x: Any, pos: int = None): + def __call__(self, x: Any, pos: int = None) -> str: """Returns the formatted string for tick position ``pos`` and value ``x``. Args: x: the tick value to format. pos: the tick label position. + + Returns: + str: the formatted tick label. """ return self.fix_minus("{:.3g}".format(x * self.factor)) diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 860ec396dd..4b1702c8ec 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -121,7 +121,9 @@ def _plot_figure(self): # Confidence interval plot if self.data_exists_for(ser, ["x_interp", "y_interp", "y_interp_err"]): - x, y_interp, y_interp_err = self.data_for(ser, ["x_interp", "y_interp", "y_interp_err"]) + x, y_interp, y_interp_err = self.data_for( + ser, ["x_interp", "y_interp", "y_interp_err"] + ) for n_sigma, alpha in self.options.plot_sigma: self.drawer.draw_filled_y_area( x, From 6cccd6b5d4853e7f10b14f3056248976708b4c6a Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 15:51:40 +0200 Subject: [PATCH 42/45] Fix failing tests resonator spectroscopy analysis --- .../analysis/resonator_spectroscopy_analysis.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py index 34823dbb0e..86760195d4 100644 --- a/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py +++ b/qiskit_experiments/library/characterization/analysis/resonator_spectroscopy_analysis.py @@ -50,7 +50,7 @@ def _run_analysis( axis = get_non_gui_ax() figure = axis.get_figure() # TODO: Move plotting to a new IQPlotter class. - figure.set_size_inches(*self.plotter.drawer.style.figsize) + figure.set_size_inches(*self.plotter.drawer.style["figsize"]) iqs = [] @@ -69,12 +69,12 @@ def _run_analysis( iqs = np.vstack(iqs) axis.scatter(iqs[:, 0], iqs[:, 1], color="b") axis.set_xlabel( - "In phase [arb. units]", fontsize=self.plotter.drawer.style.axis_label_size + "In phase [arb. units]", fontsize=self.plotter.drawer.style["axis_label_size"] ) axis.set_ylabel( - "Quadrature [arb. units]", fontsize=self.plotter.drawer.style.axis_label_size + "Quadrature [arb. units]", fontsize=self.plotter.drawer.style["axis_label_size"] ) - axis.tick_params(labelsize=self.plotter.drawer.style.tick_label_size) + axis.tick_params(labelsize=self.plotter.drawer.style["tick_label_size"]) axis.grid(True) figures.append(figure) From 2448150fdec4764daf6116c6d5f9a67e0a107a1d Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Mon, 3 Oct 2022 16:58:36 +0200 Subject: [PATCH 43/45] Add release notes --- ...add-new-visualization-module-9c6a84f2813459a7.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 releasenotes/notes/add-new-visualization-module-9c6a84f2813459a7.yaml diff --git a/releasenotes/notes/add-new-visualization-module-9c6a84f2813459a7.yaml b/releasenotes/notes/add-new-visualization-module-9c6a84f2813459a7.yaml new file mode 100644 index 0000000000..2a0ded7126 --- /dev/null +++ b/releasenotes/notes/add-new-visualization-module-9c6a84f2813459a7.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Added new visualization module to plot figures and draw onto figure canvases. The new module contains + plotters and drawers, which integrate with CurveAnalysis but can be used independently of the + analysis classes. This module replaces the old and now deprecated + `qiskit_experiments.curve_analysis.visualization` submodule. +deprecations: + - | + Deprecated `qiskit_experiments.curve_analysis.visualization` submodule as it is replaced by the new + `qiskit_experiments.visualization` submodule. From 91840386b39f0d36d7a78e0e6a023a54f5a1f3ad Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 6 Oct 2022 10:22:35 +0200 Subject: [PATCH 44/45] Rename drawer drawing methods --- .../visualization/drawers/base_drawer.py | 66 ++++++++++--------- .../drawers/legacy_curve_compat_drawer.py | 20 +++--- .../visualization/drawers/mpl_drawer.py | 10 +-- .../visualization/plotters/curve_plotter.py | 16 ++--- test/visualization/mock_drawer.py | 10 +-- test/visualization/mock_plotter.py | 7 +- test/visualization/test_mpldrawer.py | 12 ++-- 7 files changed, 72 insertions(+), 69 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index c72929ea6d..4754ce9601 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -45,31 +45,33 @@ class BaseDrawer(ABC): This method formats the appearance of the canvas. Typically, it updates axis and tick labels. Note that the axis SI unit may be specified in the drawer figure_options. In this case, axis numbers should be auto-scaled with the unit prefix. + + Drawing Methods: - draw_scatter + scatter - This method draws scatter points on the canvas, like a scatter-plot, with optional error-bars in - both the X and Y axes. + This method draws scatter points on the canvas, like a scatter-plot, with optional error-bars in + both the X and Y axes. - draw_line + line - This method plots a line from provided X and Y values. + This method plots a line from provided X and Y values. - draw_filled_y_area + filled_y_area - This method plots a shaped region bounded by upper and lower Y-values. This method is typically - called with interpolated x and a pair of y values that represent the upper and lower bound within - certain confidence interval. If this is called multiple times, it may be necessary to set the - transparency so that overlapping regions can be distinguished. + This method plots a shaped region bounded by upper and lower Y-values. This method is typically + called with interpolated x and a pair of y values that represent the upper and lower bound within + certain confidence interval. If this is called multiple times, it may be necessary to set the + transparency so that overlapping regions can be distinguished. - draw_filled_x_area + filled_x_area - This method plots a shaped region bounded by upper and lower X-values, as a function of Y-values. - This method is a rotated analogue of :meth:`draw_filled_y_area`. + This method plots a shaped region bounded by upper and lower X-values, as a function of Y-values. + This method is a rotated analogue of :meth:`filled_y_area`. - draw_text_box + textbox - This method draws a text-box on the canvas, which is a rectangular region containing some text. + This method draws a text-box on the canvas, which is a rectangular region containing some text. Options and Figure Options ========================== @@ -92,22 +94,22 @@ class BaseDrawer(ABC): Legends are generated based off of drawn graphics and their labels or names. These are managed by individual drawer subclasses, and generated when the :meth:`format_canvas` method is called. Legend - entries are created when any ``draw_*`` function is called with ``legend=True``. There are three - parameters in ``draw_*`` functions that are relevant to legend generation: ``name``, ``label``, and - ``legend``. If a user would like the graphics drawn onto a canvas, by a call to ``draw_*``, to be - used as the graphical component of a legend entry; they should set ``legend=True``. The legend entry - label can be defined in three locations: the ``label`` parameter of ``draw_*`` functions, the - ``"label"`` entry in ``series_params``, and the ``name`` parameter of ``draw_*`` functions. These - three possible label variables have a search hierarchy given by the order in the aforementioned list. - If one of the label variables is ``None``, the next is used. If all are ``None``, a legend entry is - not generated for the given series. + entries are created when any drawing function is called with ``legend=True``. There are three + parameters in drawing functions that are relevant to legend generation: ``name``, ``label``, and + ``legend``. If a user would like the graphics drawn onto a canvas to be used as the graphical + component of a legend entry; they should set ``legend=True``. The legend entry label can be defined + in three locations: the ``label`` parameter of drawing functions, the ``"label"`` entry in + ``series_params``, and the ``name`` parameter of drawing functions. These three possible label + variables have a search hierarchy given by the order in the aforementioned list. If one of the label + variables is ``None``, the next is used. If all are ``None``, a legend entry is not generated for the + given series. The recommended way to customize the legend entries is as follows: 1. Set the labels in the ``series_params`` option, keyed on the series names. 2. Initialize the canvas. - 3. Call relevant ``draw_*`` methods to create the figure. When calling the ``draw_*`` method that + 3. Call relevant drawing methods to create the figure. When calling the drawing method that creates the graphic you would like to use in the legend, set ``legend=True``. For example, - ``drawer.draw_scatter(...,legend=True)`` would use the scatter points as the legend graphics + ``drawer.scatter(...,legend=True)`` would use the scatter points as the legend graphics for the given series. 4. Format the canvas and call :meth:`figure` to get the figure. """ @@ -297,7 +299,7 @@ def label_for(self, name: Optional[str], label: Optional[str]) -> Optional[str]: return None @abstractmethod - def draw_scatter( + def scatter( self, x_data: Sequence[float], y_data: Sequence[float], @@ -326,7 +328,7 @@ def draw_scatter( """ @abstractmethod - def draw_line( + def line( self, x_data: Sequence[float], y_data: Sequence[float], @@ -351,7 +353,7 @@ def draw_line( """ @abstractmethod - def draw_filled_y_area( + def filled_y_area( self, x_data: Sequence[float], y_ub: Sequence[float], @@ -378,7 +380,7 @@ def draw_filled_y_area( """ @abstractmethod - def draw_filled_x_area( + def filled_x_area( self, x_ub: Sequence[float], x_lb: Sequence[float], @@ -405,7 +407,7 @@ def draw_filled_x_area( """ @abstractmethod - def draw_text_box( + def textbox( self, description: str, rel_pos: Optional[Tuple[float, float]] = None, @@ -415,7 +417,7 @@ def draw_text_box( Args: description: A string to be drawn inside a report box. - rel_pos: Relative position of the text-box. If None, the default ``text_box_rel_pos`` from + rel_pos: Relative position of the text-box. If None, the default ``textbox_rel_pos`` from the style is used. options: Valid options for the drawer backend API. """ diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index a1003a876c..eb0a70202c 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -36,7 +36,7 @@ class LegacyCurveCompatDrawer(BaseDrawer): .. note:: As :class:`BaseCurveDrawer` doesn't support customizing legend entries, the ``legend`` and - ``label`` parameters in ``draw_*`` methods (such as :meth:`draw_scatter`) are unsupported and + ``label`` parameters in drawing methods (such as :meth:`scatter`) are unsupported and do nothing. """ @@ -56,7 +56,7 @@ def format_canvas(self): self._curve_drawer.format_canvas() # pylint: disable=unused-argument - def draw_scatter( + def scatter( self, x_data: Sequence[float], y_data: Sequence[float], @@ -88,7 +88,7 @@ def draw_scatter( self._curve_drawer.draw_raw_data(x_data, y_data, name, **options) # pylint: disable=unused-argument - def draw_line( + def line( self, x_data: Sequence[float], y_data: Sequence[float], @@ -110,7 +110,7 @@ def draw_line( self._curve_drawer.draw_fit_line(x_data, y_data, name, **options) # pylint: disable=unused-argument - def draw_filled_y_area( + def filled_y_area( self, x_data: Sequence[float], y_ub: Sequence[float], @@ -135,7 +135,7 @@ def draw_filled_y_area( self._curve_drawer.draw_confidence_interval(x_data, y_ub, y_lb, name, **options) # pylint: disable=unused-argument - def draw_filled_x_area( + def filled_x_area( self, x_ub: Sequence[float], x_lb: Sequence[float], @@ -146,18 +146,18 @@ def draw_filled_x_area( **options, ): """Does nothing as this is functionality not supported by :class:`BaseCurveDrawer`.""" - warnings.warn(f"{self.__class__.__name__}.draw_filled_x_area is not supported.") + warnings.warn(f"{self.__class__.__name__}.filled_x_area is not supported.") # pylint: disable=unused-argument - def draw_text_box( + def textbox( self, description: str, rel_pos: Optional[Tuple[float, float]] = None, **options ): - """Draw text box. + """Draw textbox. Args: - description: A string to be drawn inside a report box. + description: A string to be drawn inside a text box. rel_pos: Unsupported as :class:`BaseCurveDrawer` doesn't support modifying the location of - text in :meth:`draw_text_box` or :meth:`BaseCurveDrawer.draw_fit_report`. + text in :meth:`textbox` or :meth:`BaseCurveDrawer.draw_fit_report`. options: Valid options for the drawer backend API. """ diff --git a/qiskit_experiments/visualization/drawers/mpl_drawer.py b/qiskit_experiments/visualization/drawers/mpl_drawer.py index bf8d2d41f2..4509e60f5e 100644 --- a/qiskit_experiments/visualization/drawers/mpl_drawer.py +++ b/qiskit_experiments/visualization/drawers/mpl_drawer.py @@ -312,7 +312,7 @@ def _update_label_in_options( if _label: options["label"] = _label - def draw_scatter( + def scatter( self, x_data: Sequence[float], y_data: Sequence[float], @@ -359,7 +359,7 @@ def draw_scatter( x_data, y_data, yerr=y_err, xerr=x_err, **errorbar_options ) - def draw_line( + def line( self, x_data: Sequence[float], y_data: Sequence[float], @@ -381,7 +381,7 @@ def draw_line( draw_ops.update(**options) self._get_axis(axis).plot(x_data, y_data, **draw_ops) - def draw_filled_y_area( + def filled_y_area( self, x_data: Sequence[float], y_ub: Sequence[float], @@ -403,7 +403,7 @@ def draw_filled_y_area( draw_ops.update(**options) self._get_axis(axis).fill_between(x_data, y1=y_lb, y2=y_ub, **draw_ops) - def draw_filled_x_area( + def filled_x_area( self, x_ub: Sequence[float], x_lb: Sequence[float], @@ -425,7 +425,7 @@ def draw_filled_x_area( draw_ops.update(**options) self._get_axis(axis).fill_betweenx(y_data, x1=x_lb, x2=x_ub, **draw_ops) - def draw_text_box( + def textbox( self, description: str, rel_pos: Optional[Tuple[float, float]] = None, diff --git a/qiskit_experiments/visualization/plotters/curve_plotter.py b/qiskit_experiments/visualization/plotters/curve_plotter.py index 4b1702c8ec..97be6837b1 100644 --- a/qiskit_experiments/visualization/plotters/curve_plotter.py +++ b/qiskit_experiments/visualization/plotters/curve_plotter.py @@ -60,8 +60,8 @@ def expected_supplementary_data_keys(cls) -> List[str]: Data Keys: report_text: A string containing any fit report information to be drawn in a box. - The style and position of the report is controlled by ``text_box_rel_pos`` and - ``text_box_text_size`` style parameters in :class:`PlotStyle`. + The style and position of the report is controlled by ``textbox_rel_pos`` and + ``textbox_text_size`` style parameters in :class:`PlotStyle`. """ return [ "report_text", @@ -90,7 +90,7 @@ def _plot_figure(self): plotted_formatted_data = False if self.data_exists_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]): x, y, yerr = self.data_for(ser, ["x_formatted", "y_formatted", "y_formatted_err"]) - self.drawer.draw_scatter(x, y, y_err=yerr, name=ser, zorder=2, legend=True) + self.drawer.scatter(x, y, y_err=yerr, name=ser, zorder=2, legend=True) plotted_formatted_data = True # Scatter plot @@ -104,10 +104,10 @@ def _plot_figure(self): if plotted_formatted_data: options["color"] = "gray" # If we didn't plot formatted data, the X-Y markers should be used for the legend. We add - # it to ``options`` so it's easier to pass to ``draw_scatter``. + # it to ``options`` so it's easier to pass to ``scatter``. if not plotted_formatted_data: options["legend"] = True - self.drawer.draw_scatter( + self.drawer.scatter( x, y, name=ser, @@ -117,7 +117,7 @@ def _plot_figure(self): # Line plot for fit if self.data_exists_for(ser, ["x_interp", "y_interp"]): x, y = self.data_for(ser, ["x_interp", "y_interp"]) - self.drawer.draw_line(x, y, name=ser, zorder=3) + self.drawer.line(x, y, name=ser, zorder=3) # Confidence interval plot if self.data_exists_for(ser, ["x_interp", "y_interp", "y_interp_err"]): @@ -125,7 +125,7 @@ def _plot_figure(self): ser, ["x_interp", "y_interp", "y_interp_err"] ) for n_sigma, alpha in self.options.plot_sigma: - self.drawer.draw_filled_y_area( + self.drawer.filled_y_area( x, y_interp + n_sigma * y_interp_err, y_interp - n_sigma * y_interp_err, @@ -137,4 +137,4 @@ def _plot_figure(self): # Fit report if "report_text" in self.supplementary_data: report_text = self.supplementary_data["report_text"] - self.drawer.draw_text_box(report_text) + self.drawer.textbox(report_text) diff --git a/test/visualization/mock_drawer.py b/test/visualization/mock_drawer.py index 34dd194889..3221dc67d2 100644 --- a/test/visualization/mock_drawer.py +++ b/test/visualization/mock_drawer.py @@ -48,7 +48,7 @@ def format_canvas(self): """Does nothing.""" pass - def draw_scatter( + def scatter( self, x_data: Sequence[float], y_data: Sequence[float], @@ -62,7 +62,7 @@ def draw_scatter( """Does nothing.""" pass - def draw_line( + def line( self, x_data: Sequence[float], y_data: Sequence[float], @@ -74,7 +74,7 @@ def draw_line( """Does nothing.""" pass - def draw_filled_y_area( + def filled_y_area( self, x_data: Sequence[float], y_ub: Sequence[float], @@ -87,7 +87,7 @@ def draw_filled_y_area( """Does nothing.""" pass - def draw_filled_x_area( + def filled_x_area( self, x_ub: Sequence[float], x_lb: Sequence[float], @@ -100,7 +100,7 @@ def draw_filled_x_area( """Does nothing.""" pass - def draw_text_box( + def textbox( self, description: str, rel_pos: Optional[Tuple[float, float]] = None, diff --git a/test/visualization/mock_plotter.py b/test/visualization/mock_plotter.py index 402df6787b..c693c9ba44 100644 --- a/test/visualization/mock_plotter.py +++ b/test/visualization/mock_plotter.py @@ -14,7 +14,8 @@ """ from typing import List -from qiskit_experiments.visualization import BasePlotter, BaseDrawer + +from qiskit_experiments.visualization import BaseDrawer, BasePlotter class MockPlotter(BasePlotter): @@ -47,13 +48,13 @@ def _plot_figure(self): """Plots a figure if :attr:`plotting_enabled` is True. If :attr:`plotting_enabled` is True, :class:`MockPlotter` calls - :meth:`~BaseDrawer.draw_formatted_data` for a series titled ``seriesA`` with ``x``, ``y``, and + :meth:`~BaseDrawer.scatter` for a series titled ``seriesA`` with ``x``, ``y``, and ``z`` data-keys assigned to the x and y values and the y-error/standard deviation respectively. If :attr:`drawer` generates a figure, then :meth:`figure` should return a scatterplot figure with error-bars. """ if self.plotting_enabled: - self.drawer.draw_formatted_data(*self.data_for("seriesA", ["x", "y", "z"]), "seriesA") + self.drawer.scatter(*self.data_for("seriesA", ["x", "y", "z"]), "seriesA") @classmethod def expected_series_data_keys(cls) -> List[str]: diff --git a/test/visualization/test_mpldrawer.py b/test/visualization/test_mpldrawer.py index c4c2230631..2ecb377fb9 100644 --- a/test/visualization/test_mpldrawer.py +++ b/test/visualization/test_mpldrawer.py @@ -29,12 +29,12 @@ def test_end_to_end(self): # Draw dummy data drawer.initialize_canvas() - drawer.draw_scatter([0, 1, 2], [0, 1, 2], name="seriesA") - drawer.draw_scatter([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], None, name="seriesA") - drawer.draw_line([3, 2, 1], [1, 2, 3], name="seriesB") - drawer.draw_filled_x_area([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], name="seriesB") - drawer.draw_filled_y_area([-1, 0, 1, 2], [-1, -2, -1, -2], [1, 2, 1, 2], name="seriesB") - drawer.draw_text_box(r"Dummy report text with LaTex $\beta$") + drawer.scatter([0, 1, 2], [0, 1, 2], name="seriesA") + drawer.scatter([0, 1, 2], [0, 1, 2], [0.1, 0.1, 0.1], None, name="seriesA") + drawer.line([3, 2, 1], [1, 2, 3], name="seriesB") + drawer.filled_x_area([0, 1, 2, 3], [1, 2, 1, 2], [-1, -2, -1, -2], name="seriesB") + drawer.filled_y_area([-1, 0, 1, 2], [-1, -2, -1, -2], [1, 2, 1, 2], name="seriesB") + drawer.textbox(r"Dummy report text with LaTex $\beta$") # Get result fig = drawer.figure From 8f461d186a0b8260fa0ff57be002717ee6a9d5e3 Mon Sep 17 00:00:00 2001 From: Conrad Haupt Date: Thu, 6 Oct 2022 11:16:12 +0200 Subject: [PATCH 45/45] Fix lint --- .../visualization/drawers/base_drawer.py | 25 ++++++++++--------- .../drawers/legacy_curve_compat_drawer.py | 5 +++- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/qiskit_experiments/visualization/drawers/base_drawer.py b/qiskit_experiments/visualization/drawers/base_drawer.py index 4754ce9601..61f2917f9f 100644 --- a/qiskit_experiments/visualization/drawers/base_drawer.py +++ b/qiskit_experiments/visualization/drawers/base_drawer.py @@ -45,13 +45,13 @@ class BaseDrawer(ABC): This method formats the appearance of the canvas. Typically, it updates axis and tick labels. Note that the axis SI unit may be specified in the drawer figure_options. In this case, axis numbers should be auto-scaled with the unit prefix. - + Drawing Methods: scatter - This method draws scatter points on the canvas, like a scatter-plot, with optional error-bars in - both the X and Y axes. + This method draws scatter points on the canvas, like a scatter-plot, with optional error-bars + in both the X and Y axes. line @@ -59,19 +59,20 @@ class BaseDrawer(ABC): filled_y_area - This method plots a shaped region bounded by upper and lower Y-values. This method is typically - called with interpolated x and a pair of y values that represent the upper and lower bound within - certain confidence interval. If this is called multiple times, it may be necessary to set the - transparency so that overlapping regions can be distinguished. + This method plots a shaped region bounded by upper and lower Y-values. This method is + typically called with interpolated x and a pair of y values that represent the upper and + lower bound within certain confidence interval. If this is called multiple times, it may be + necessary to set the transparency so that overlapping regions can be distinguished. filled_x_area - This method plots a shaped region bounded by upper and lower X-values, as a function of Y-values. - This method is a rotated analogue of :meth:`filled_y_area`. + This method plots a shaped region bounded by upper and lower X-values, as a function of + Y-values. This method is a rotated analogue of :meth:`filled_y_area`. textbox - This method draws a text-box on the canvas, which is a rectangular region containing some text. + This method draws a text-box on the canvas, which is a rectangular region containing some + text. Options and Figure Options ========================== @@ -109,8 +110,8 @@ class BaseDrawer(ABC): 2. Initialize the canvas. 3. Call relevant drawing methods to create the figure. When calling the drawing method that creates the graphic you would like to use in the legend, set ``legend=True``. For example, - ``drawer.scatter(...,legend=True)`` would use the scatter points as the legend graphics - for the given series. + ``drawer.scatter(...,legend=True)`` would use the scatter points as the legend graphics for + the given series. 4. Format the canvas and call :meth:`figure` to get the figure. """ diff --git a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py index eb0a70202c..8081eac436 100644 --- a/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py +++ b/qiskit_experiments/visualization/drawers/legacy_curve_compat_drawer.py @@ -150,7 +150,10 @@ def filled_x_area( # pylint: disable=unused-argument def textbox( - self, description: str, rel_pos: Optional[Tuple[float, float]] = None, **options + self, + description: str, + rel_pos: Optional[Tuple[float, float]] = None, + **options, ): """Draw textbox.