From 07cb9a22e5c473189b1dfefe6d26ab518b495ff4 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 8 Sep 2023 17:55:04 +0200 Subject: [PATCH 01/55] Post-release --- VERSION | 2 +- docs/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0c84780..427c923 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev26 +0.9.0.dev27 diff --git a/docs/changelog.rst b/docs/changelog.rst index d0741f7..e6aa690 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,7 +14,7 @@ Version 0.9.0 Version 0.8.3 ============= -*Not yet released* +Released 2023-09-08 Fixes ----- From ca0766396de6d3cea18fc36e1294f136c8a161c3 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 8 Sep 2023 18:23:15 +0200 Subject: [PATCH 02/55] CommonRangeExtraction works for ND datasets with arbitrary dimension N --- VERSION | 2 +- aspecd/processing.py | 30 +++------------------------ docs/changelog.rst | 12 +++++++++++ tests/test_processing.py | 45 +++++++++++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 36 deletions(-) diff --git a/VERSION b/VERSION index 427c923..64519bec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev27 +0.9.0.dev28 diff --git a/aspecd/processing.py b/aspecd/processing.py index ac3459d..ca06016 100644 --- a/aspecd/processing.py +++ b/aspecd/processing.py @@ -2960,12 +2960,6 @@ class CommonRangeExtraction(MultiProcessingStep): agree. This usually requires interpolating the data to a common set of axes. - .. important:: - Currently, extracting the common range works *only* for **1D and 2D** - datasets, not for higher-dimensional datasets, due to the underlying - method of interpolation. See :class:`Interpolation` for details. This - may, however, change in the future. - .. todo:: * Make type of interpolation controllable @@ -3079,6 +3073,9 @@ class CommonRangeExtraction(MultiProcessingStep): Unit of last axis (*i.e.*, intensity) gets ignored when checking for same units + .. versionchanged:: 0.8.4 + Works for *N*\ D datasets with arbitrary dimension *N* + """ def __init__(self): @@ -3089,27 +3086,6 @@ def __init__(self): self.parameters["common_range"] = [] self.parameters["npoints"] = [] - @staticmethod - def applicable(dataset): - """ - Check whether processing step is applicable to the given dataset. - - Extracting a common range is currently only applicable to datasets with - one- and two-dimensional data, due to the underlying interpolation. - - Parameters - ---------- - dataset : :class:`aspecd.dataset.Dataset` - dataset to check - - Returns - ------- - applicable : :class:`bool` - `True` if successful, `False` otherwise. - - """ - return len(dataset.data.axes) <= 3 - def _sanitise_parameters(self): if len(self.datasets) < 2: raise IndexError("Need more than one dataset") diff --git a/docs/changelog.rst b/docs/changelog.rst index e6aa690..f58fce6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,18 @@ Version 0.9.0 *Not yet released* +Version 0.8.4 +============= + +*Not yet released* + + +New features +------------ + +* :class:`aspecd.processing.CommonRangeExtraction` works for *N*\ D datasets with arbitrary dimension *N* + + Version 0.8.3 ============= diff --git a/tests/test_processing.py b/tests/test_processing.py index 2e081f0..97a9413 100644 --- a/tests/test_processing.py +++ b/tests/test_processing.py @@ -2151,14 +2151,6 @@ def test_with_only_one_dataset_raises(self): with self.assertRaisesRegex(IndexError, "Need more than one dataset"): self.processing.process() - def test_with_datasets_with_more_than_2d_raises(self): - self.dataset1.data.data = np.random.random([10, 10, 10]) - self.dataset2.data.data = np.random.random([10, 10, 10]) - self.processing.datasets.append(self.dataset1) - self.processing.datasets.append(self.dataset2) - with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): - self.processing.process() - def test_process_with_datasets_with_different_dimensions_raises(self): self.dataset1.data.data = np.random.random(10) self.dataset2.data.data = np.random.random([10, 10]) @@ -2329,6 +2321,43 @@ def test_process_interpolates_data_for_2d_datasets(self): self.assertTrue(np.all(self.dataset2.data.data == self.dataset1.data.data)) + def test_process_sets_npoints_for_3d_datasets(self): + self.dataset1.data.data = np.random.random([11, 11, 11]) + self.dataset1.data.axes[0].values = np.linspace(0, 5, 11) + self.dataset1.data.axes[1].values = np.linspace(10, 15, 11) + self.dataset1.data.axes[2].values = np.linspace(20, 25, 11) + self.dataset2.data.data = np.random.random([11, 11, 11]) + self.dataset2.data.axes[0].values = np.linspace(1, 4, 11) + self.dataset2.data.axes[1].values = np.linspace(11, 14, 11) + self.dataset2.data.axes[2].values = np.linspace(21, 24, 11) + self.processing.datasets.append(self.dataset1) + self.processing.datasets.append(self.dataset2) + self.processing.process() + self.assertEqual([7, 7, 7], self.processing.parameters["npoints"]) + + def test_process_interpolates_data_for_3d_datasets(self): + axis0 = np.linspace(0, 5, 11) + axis1 = np.linspace(0, 5, 11) + axis2 = np.linspace(0, 5, 11) + xx, yy, zz = np.meshgrid(axis0, axis1, axis2, indexing='ij') + self.dataset1.data.data = np.sin(xx**2 + yy**2 + zz**2) + self.dataset1.data.axes[0].values = axis0 + self.dataset1.data.axes[1].values = axis1 + self.dataset1.data.axes[2].values = axis2 + axis0 = np.linspace(2, 4, 5) + axis1 = np.linspace(2, 4, 5) + axis2 = np.linspace(2, 4, 5) + xx, yy, zz = np.meshgrid(axis0, axis1, axis2, indexing='ij') + self.dataset2.data.data = np.sin(xx**2 + yy**2 + zz**2) + self.dataset2.data.axes[0].values = axis0 + self.dataset2.data.axes[1].values = axis1 + self.dataset2.data.axes[2].values = axis2 + self.processing.datasets.append(self.dataset1) + self.processing.datasets.append(self.dataset2) + self.processing.process() + self.assertTrue(np.all(self.dataset2.data.data + == self.dataset1.data.data)) + class TestNoise(unittest.TestCase): From ef63e686411e3f9480f032409748908f79db93ad Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 8 Sep 2023 18:48:38 +0200 Subject: [PATCH 03/55] Cleanup after prospector run --- VERSION | 2 +- aspecd/processing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 64519bec..25370b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev28 +0.9.0.dev29 diff --git a/aspecd/processing.py b/aspecd/processing.py index ca06016..765337c 100644 --- a/aspecd/processing.py +++ b/aspecd/processing.py @@ -2952,7 +2952,7 @@ def _convert_filter_type(self): class CommonRangeExtraction(MultiProcessingStep): # noinspection PyUnresolvedReferences - """ + r""" Extract the common range of data for multiple datasets using interpolation. One prerequisite for adding up multiple datasets in a meaningful way is to From 2a2c3a30b469bab71b736e9e1713f6d1df94aab5 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 8 Sep 2023 19:21:24 +0200 Subject: [PATCH 04/55] Legend title can be set from recipes --- VERSION | 2 +- aspecd/plotting.py | 9 +++++++++ docs/changelog.rst | 1 + docs/roadmap.rst | 3 --- tests/test_plotting.py | 12 ++++++++++++ 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 25370b2..fa6a290 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev29 +0.9.0.dev30 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 404b12e..86beb0f 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -3229,6 +3229,11 @@ class LegendProperties(aspecd.utils.Properties): Default: 1 + title : :class:`str` + Title of the legend + + Default: empty + Raises ------ aspecd.exceptions.MissingLegendError @@ -3241,6 +3246,9 @@ class LegendProperties(aspecd.utils.Properties): .. versionchanged:: 0.8 Added attribute :attr:`ncol` + .. versionchanged:: 0.8.4 + Added attribute :attr:`title` + """ def __init__(self): @@ -3250,6 +3258,7 @@ def __init__(self): self.labelspacing = 0.5 self.fontsize = plt.rcParams['font.size'] self.ncol = 1 + self.title = '' self._exclude = ['location'] self._exclude_from_to_dict = ['location'] diff --git a/docs/changelog.rst b/docs/changelog.rst index f58fce6..a63e253 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -21,6 +21,7 @@ New features ------------ * :class:`aspecd.processing.CommonRangeExtraction` works for *N*\ D datasets with arbitrary dimension *N* +* Legend title can be set from recipes Version 0.8.3 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 8adfaa7..d6f8a71 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -21,7 +21,6 @@ For version 0.9 * If figure is plotted twice using automatically generated filenames, use different filenames (e.g. increment number). * Axis direction can be switched (*e.g.*, for FTIR data, hence not needing to set axis limits in reverse order) * Bugfix: Title of figure and axis label overlap when adding an axis on the top side of the figure - * Allow to add a legend title in recipes. * Processing @@ -72,7 +71,6 @@ For later versions * Plot styles - * Switch in recipe settings for applying a style to all plots * user-defined styles * Annotations @@ -87,7 +85,6 @@ For later versions * Interpolation - * for ND with N>2 * different types of interpolation * Templates for creating derived packages diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 7330a3c..2701ee4 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -2181,6 +2181,18 @@ def test_ncol_sets_legend_columns(self): self.assertEqual(ncol, legend._ncols) plt.close(plot.figure) + def test_title_sets_legend_title(self): + title = 'My fancy title' + self.legend_properties.title = title + plot = plotting.Plotter() + plot.properties.legend = self.legend_properties + plot.parameters['show_legend'] = True + with contextlib.redirect_stderr(io.StringIO()): + plot.plot() + legend = plot.legend + self.assertEqual(title, legend.get_title().get_text()) + plt.close(plot.figure) + class TestGridProperties(unittest.TestCase): def setUp(self): From f6709c5683f194238e96e8928acdc0eeef53e241 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 10 Sep 2023 07:58:45 +0200 Subject: [PATCH 05/55] Update roadmap --- VERSION | 2 +- aspecd/tasks.py | 17 ++++++++++------- docs/roadmap.rst | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/VERSION b/VERSION index fa6a290..c97556b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev30 +0.9.0.dev31 diff --git a/aspecd/tasks.py b/aspecd/tasks.py index a9abda6..1333756 100644 --- a/aspecd/tasks.py +++ b/aspecd/tasks.py @@ -867,13 +867,16 @@ class from the :mod:`aspecd.processing` module. The same is true for .. todo:: - There is a number of things that are not yet implemented, but highly - recommended for a working recipe-driven data analysis that follows good - practice for reproducible research. This includes (but may not be - limited to): - - * Parser for recipes performing a static analysis of their syntax. - Useful particularly for larger datasets and/or longer lists of tasks. + While generally, recipe-driven data analysis works well in practice, + improving usability and robustness is high on the list. This includes ( + but may not be limited to) a parser for recipes performing a static + analysis of their syntax and is particularly useful for larger + datasets and/or longer lists of tasks. Potential steps in this direction: + + * Add :meth:`check` method to :class:`aspecd.tasks.Task` + * Define required parameters in a (private) attribute of either the + individual task level or even on the level of the underlying objects + * Potentially reuse the :meth:`_sanitise_parameters` method. """ import argparse diff --git a/docs/roadmap.rst b/docs/roadmap.rst index d6f8a71..415c7d7 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -5,12 +5,18 @@ Roadmap A few ideas how to develop the project further, currently a list as a reminder for the main developers themselves, in no particular order, though with a tendency to list more important aspects first: -For version 0.9 -=============== +For next releases +================= + +* Logging + + * Add loggers from other modules (than task) and derived packages + + Probably this means to switch to package-wide logging and documenting that derived packages need to log to the ASpecD logger as well. * Usability - * Importer/ImporterFactory should issue a warning if no dataset could be loaded, rather than silently continuing, as this often leads to downstream problems and exceptions thrown. + * Importer/ImporterFactory should issue a warning if no dataset could be loaded, rather than silently continuing, as this often leads to downstream problems and exceptions thrown. (Requires changes in the way logging is currently done.) * Plotting @@ -21,6 +27,7 @@ For version 0.9 * If figure is plotted twice using automatically generated filenames, use different filenames (e.g. increment number). * Axis direction can be switched (*e.g.*, for FTIR data, hence not needing to set axis limits in reverse order) * Bugfix: Title of figure and axis label overlap when adding an axis on the top side of the figure + * Quiver plots * Processing @@ -30,11 +37,9 @@ For version 0.9 * :class:`aspecd.processing.BaselineCorrection` with ``fit_area`` definable as axis range, and arbitrary parts of the axis (*e.g.*, in the middle of a dataset or with separate fit areas) -* Logging +* Datasets - * Add loggers from other modules (than task) and derived packages - - Probably this means to switch to package-wide logging and documenting that derived packages need to log to the ASpecD logger as well. + * Handling of additional axes/parameters that are logged parallel to a measurement, requiring extension of the dataset model. * Add export tasks to dataset tasks From 555ecab3b2bbefc737b49d30f59741ebb8473089 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 10 Sep 2023 20:19:16 +0200 Subject: [PATCH 06/55] Update an restructure roadmap --- VERSION | 2 +- docs/roadmap.rst | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index c97556b..7d565cf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev31 +0.9.0.dev32 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 415c7d7..9aacee2 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -21,14 +21,23 @@ For next releases * Plotting * Colorbar for 2D plotter + + https://matplotlib.org/stable/api/figure_api.html#matplotlib.figure.Figure.colorbar + * (Arbitrary) lines in plot, *e.g.* to compare peak positions Need to decide whether this goes into plotter properties or gets handled as proper annotations; probably the former, but a good starting point to think about the latter. - * If figure is plotted twice using automatically generated filenames, use different filenames (e.g. increment number). + * Axis direction can be switched (*e.g.*, for FTIR data, hence not needing to set axis limits in reverse order) + + Currently "show stopper" for example with FTIR data. + * Bugfix: Title of figure and axis label overlap when adding an axis on the top side of the figure + * Quiver plots + https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.quiver.html + * Processing * CombineDatasets: combine data from several datasets into a single dataset; parameters allowing to define the axis values/quantity/unit, possibly even from given metadata; to decide: How to handle metadata that might be invalidated? @@ -41,12 +50,16 @@ For next releases * Handling of additional axes/parameters that are logged parallel to a measurement, requiring extension of the dataset model. -* Add export tasks to dataset tasks + * Add export tasks to dataset tasks * Recipe-driven data analysis: * Better handling of automatically generated filenames for saving plots and reports: unique filenames; using the label rather than the source (id) of the dataset + * If figure is plotted twice using automatically generated filenames, use different filenames (e.g. increment number). + + Points towards reworking the :class:`aspecd.plotting.Saver` class, allowing for an additional optional parameter ``suffix`` or else. Would make handling too long filenames easier as well. + * Handling of results: automatically add datasets to dataset list? How to deal with result labels identical to existing datasets? * Sub-recipes that can be included in recipes From f6925c7322e382f9b0cce1d03f8ea37e25b6ba38 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 10 Sep 2023 20:47:42 +0200 Subject: [PATCH 07/55] List of plot properties classes in documentation; add comment on setting titles. --- VERSION | 2 +- aspecd/plotting.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7d565cf..f960b10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev32 +0.9.0.dev33 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 86beb0f..21b7167 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -133,6 +133,60 @@ line-type plots, including (semi)log plots +Properties of plot(ter)s +======================== + +Plots can be controlled extensively regarding their appearance. While +Matplotlib provides both, sensible defaults and an extensive list of +customisation options for the plot appearance, the ASpecD framework tries +to homogenise these settings and currently provides only a subset of the +possibilities the underlying Matplotlib library would allow. + +The properties of plots and their individual components are reflected in a +hierarchy of objects. Each plotter has a corresponding :attr:`properties` +attribute that contains an object of the respective +:class:`aspecd.plotting.PlotProperties` class. + +To give you an idea of the hierarchy of classes handling the plot +properties, below is a (hopefully complete) list: + + * :class:`aspecd.plotting.PlotProperties` + + * :class:`aspecd.plotting.SinglePlotProperties` + + * :class:`aspecd.plotting.SinglePlot1DProperties` + * :class:`aspecd.plotting.SinglePlot2DProperties` + + * :class:`aspecd.plotting.MultiPlotProperties` + + * :class:`aspecd.plotting.MultiPlot1DProperties` + + * :class:`aspecd.plotting.CompositePlotProperties` + + * :class:`aspecd.plotting.FigureProperties` + + * :class:`aspecd.plotting.AxesProperties` + + * :class:`aspecd.plotting.LegendProperties` + + * :class:`aspecd.plotting.DrawingProperties` + + * :class:`aspecd.plotting.LineProperties` + * :class:`aspecd.plotting.SurfaceProperties` + + * :class:`aspecd.plotting.GridProperties` + +Getting and setting plot properties is somewhat complicated by the fact +that Matplotlib usually allows setting properties only when instantiating +objects, or sometimes with explicit setter methods. Similarly, there may +or may not be getter methods for the relevant attributes. + +In any case, while you can set and get properties of plots +programmatically within the ASpecD framework, using :doc:`recipe-driven data +analysis <../recipes>` is highly recommended. + + + Plotting to existing axes ========================= @@ -2955,6 +3009,12 @@ class FigureProperties(aspecd.utils.Properties): title: :class:`str` Title for the figure as a whole + .. important:: + + If you have a second axis on top of the axes, setting the + figure title will result in the figure title clashing with the + upper axis. Hence, in such case, try setting the axis title. + Raises ------ aspecd.exceptions.MissingFigureError @@ -3028,6 +3088,12 @@ class AxesProperties(aspecd.utils.Properties): Note that this is a per-axis title, unlike the figure title set for the whole figure. + .. important:: + + If you have a second axis on top of the axes, setting the + figure title will result in the figure title clashing with the + upper axis. Hence, in such case, try setting the axis title. + Default: '' xlabel: :class:`str` From afd3920317f2373d3fa8fb41e3fd02d61d75465c Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 11 Sep 2023 00:18:28 +0200 Subject: [PATCH 08/55] Axes can be inverted --- VERSION | 2 +- aspecd/plotting.py | 33 +++++++++++++++++++- docs/changelog.rst | 1 + docs/roadmap.rst | 6 ---- tests/test_plotting.py | 69 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 102 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index f960b10..6262d37 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev33 +0.9.0.dev34 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 21b7167..83641b9 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -3160,6 +3160,23 @@ class AxesProperties(aspecd.utils.Properties): Default: None + invert: :class:`list` or :class:`str` + Axes to invert + + Sometimes, inverted axes are the default, *e.g.* the wavenumber + axis in FTIR spectroscopy. While dedicated packages for such + method based on the ASpecD framework will take care of these + specialties, this option allows for full flexibility. + + Can either be a single value, such as 'x' or 'y', or a list, + such as ['x'] or even ['x', 'y']. + + .. note:: + + An alternative option to invert an axis is to provide + descending values for axis limits. However, this may be + inconvenient if you don't want to explicitly provide axis limits. + Raises ------ aspecd.exceptions.MissingAxisError @@ -3169,6 +3186,9 @@ class AxesProperties(aspecd.utils.Properties): .. versionchanged:: 0.6 New properties ``xticklabelangle`` and ``yticklabelangle`` + .. versionchanged:: 0.8.4 + New property ``invert`` + """ def __init__(self): @@ -3189,6 +3209,7 @@ def __init__(self): self.yticklabels = None self.yticklabelangle = 0.0 self.yticks = None + self.invert = None def apply(self, axes=None): """ @@ -3227,6 +3248,16 @@ def apply(self, axes=None): tick.set_rotation(self.xticklabelangle) for tick in axes.get_yticklabels(): tick.set_rotation(self.yticklabelangle) + if self.invert: + if isinstance(self.invert, str): + self.invert = [self.invert] + for axis in self.invert: + if axis.lower().startswith('x'): + if not axes.xaxis_inverted(): + axes.invert_xaxis() + if axis.lower().startswith('y'): + if not axes.yaxis_inverted(): + axes.invert_yaxis() def _get_settable_properties(self): """ @@ -3246,7 +3277,7 @@ def _get_settable_properties(self): all_properties = self.to_dict() properties = {} for prop in all_properties: - if prop.startswith(('xtick', 'ytick')): + if prop.startswith(('xtick', 'ytick', 'invert')): pass elif isinstance(all_properties[prop], np.ndarray): if any(all_properties[prop]): diff --git a/docs/changelog.rst b/docs/changelog.rst index a63e253..41a6a37 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,7 @@ New features * :class:`aspecd.processing.CommonRangeExtraction` works for *N*\ D datasets with arbitrary dimension *N* * Legend title can be set from recipes +* New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. Version 0.8.3 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 9aacee2..9dfebb4 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -28,12 +28,6 @@ For next releases Need to decide whether this goes into plotter properties or gets handled as proper annotations; probably the former, but a good starting point to think about the latter. - * Axis direction can be switched (*e.g.*, for FTIR data, hence not needing to set axis limits in reverse order) - - Currently "show stopper" for example with FTIR data. - - * Bugfix: Title of figure and axis label overlap when adding an axis on the top side of the figure - * Quiver plots https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.quiver.html diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 2701ee4..2600e4c 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1962,7 +1962,7 @@ def test_has_properties(self): for prop in ['aspect', 'facecolor', 'position', 'title', 'xlabel', 'xlim', 'xscale', 'xticklabels', 'xticks', 'ylabel', 'ylim', 'yscale', 'yticklabels', 'yticks', - 'xticklabelangle', 'yticklabelangle', + 'xticklabelangle', 'yticklabelangle', 'invert' ]: self.assertTrue(hasattr(self.axis_properties, prop)) @@ -2058,6 +2058,73 @@ def test_set_ytick_label_angle(self): plot.axes.get_yticklabels()[0].get_rotation()) plt.close(plot.figure) + def test_invert_x_axis_with_scalar_value(self): + self.axis_properties.invert = 'x' + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.xaxis_inverted()) + plt.close(plot.figure) + + def test_invert_y_axis_with_scalar_value(self): + self.axis_properties.invert = 'y' + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.yaxis_inverted()) + plt.close(plot.figure) + + def test_invert_x_axis_with_scalar_capital_value(self): + self.axis_properties.invert = 'X' + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.xaxis_inverted()) + plt.close(plot.figure) + + def test_invert_x_axis_with_string_starting_with_x(self): + self.axis_properties.invert = 'xaxis' + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.xaxis_inverted()) + plt.close(plot.figure) + + def test_invert_x_axis_with_list_value(self): + self.axis_properties.invert = ['x'] + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.xaxis_inverted()) + plt.close(plot.figure) + + def test_invert_both_axes(self): + self.axis_properties.invert = ['x', 'y'] + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.xaxis_inverted()) + self.assertTrue(plot.axes.yaxis_inverted()) + plt.close(plot.figure) + + def test_invert_does_not_invert_already_inverted_x_axis(self): + self.axis_properties.invert = ['x'] + plot = plotting.Plotter() + plot.plot() + plot.axes.invert_xaxis() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.xaxis_inverted()) + plt.close(plot.figure) + + def test_invert_does_not_invert_already_inverted_y_axis(self): + self.axis_properties.invert = ['y'] + plot = plotting.Plotter() + plot.plot() + plot.axes.invert_yaxis() + self.axis_properties.apply(axes=plot.axes) + self.assertTrue(plot.axes.yaxis_inverted()) + plt.close(plot.figure) + class TestLegendProperties(unittest.TestCase): def setUp(self): From 53d967c050a24343c4af3def917bf94d41215588 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 11 Sep 2023 18:50:10 +0200 Subject: [PATCH 09/55] Add FTIR example --- VERSION | 2 +- docs/examples/ftir-normalised.png | Bin 0 -> 59893 bytes docs/examples/ftir-normalised.yaml | 47 +++++++++++++++++++++++++++++ docs/examples/ftir.rst | 40 ++++++++++++++++++++++++ docs/examples/index.rst | 2 +- docs/examples/list.rst | 1 + 6 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 docs/examples/ftir-normalised.png create mode 100644 docs/examples/ftir-normalised.yaml create mode 100644 docs/examples/ftir.rst diff --git a/VERSION b/VERSION index 6262d37..ea24850 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev34 +0.9.0.dev35 diff --git a/docs/examples/ftir-normalised.png b/docs/examples/ftir-normalised.png new file mode 100644 index 0000000000000000000000000000000000000000..b3dec343091bc86caa3fbf8ecb64610c6737ad56 GIT binary patch literal 59893 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 s!LC%H{f(`&NyJ>(g`j;Exg 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 F{}=Yqvn&7r literal 0 HcmV?d00001 diff --git a/docs/examples/ftir-normalised.yaml b/docs/examples/ftir-normalised.yaml new file mode 100644 index 0000000..ca39d90 --- /dev/null +++ b/docs/examples/ftir-normalised.yaml @@ -0,0 +1,47 @@ +format: + type: ASpecD recipe + version: '0.2' + +datasets: + - source: AL-C1_1.csv + label: Substance 1 + importer: TxtImporter + importer_parameters: + skiprows: 2 + delimiter: ';' + - source: AL-C2_1.csv + label: Substance 2 + importer: TxtImporter + importer_parameters: + skiprows: 2 + delimiter: ';' + +tasks: + - kind: processing + type: BaselineCorrection + properties: + parameters: + fit_area: [5, 0] + + - kind: processing + type: Normalisation + properties: + parameters: + range: [1750, 1680] + range_unit: axis + kind: minimum + + - kind: multiplot + type: MultiPlotter1D + properties: + properties: + axes: + # xlim: [4000, 635] + invert: x + xlabel: '$wavenumber$ / cm$^{-1}$' + ylabel: '$normalised\ transmission$' + parameters: + tight_layout: True + show_legend: True + filename: ftir-normalised.pdf + diff --git a/docs/examples/ftir.rst b/docs/examples/ftir.rst new file mode 100644 index 0000000..f9cacee --- /dev/null +++ b/docs/examples/ftir.rst @@ -0,0 +1,40 @@ +=========================================== +FTIR spectra normalised to spectral feature +=========================================== + +Processing and displaying Fourier-transform infrared (FTIR) spectra, though generally pretty straight-forward, provides some rather hidden obstacles from the perspective of a general data analysis framework. This is due to two aspects of FTIR spectra not common to other spectroscopy data: Spectra are often displayed as transmission, *i.e.* with "negative" features, and the conventional *x* axis (in wavenumbers) is inverted. + +In this particular example, FTIR spectra for two different samples have been recorded, and we are interested in comparing the spectra normalised to a particular spectral feature in the range of 1680 to 1750 wavenumbers. + +To this end, a series of tasks needs to be performed on each dataset: + +#. Import the data (assuming ASCII export) + +#. Correct for DC offset, *i.e.* perform a baseline correction of 0-th order. + +#. Normalise to the spectral feature used as a reference, but explicitly *not* to the entire recorded range. + +#. Plot both spectra in one axis for graphical display of recorded data, following the convention in FTIR to plot an inverse *x* axis. + +There are two ways to invert an axis: The preferred method is to explicitly set the axis property (note that you can specify which axis to invert or even both, if you provide a list). Alternatively, shown here as a comment, is to provide axis limits in descending order. While the latter method does do the trick, you need to explicitly provide axis limits in this case. This might, however, not be convenient. + +In case of the data used here, the *x* axis is recorded in descending order. Therefore, for the baseline correction step, the five percent fitting range are taken from the left part in the figure, *i.e.* at high wavenumbers. Depending on how your data were recorded and how you set your plot initially, this may be confusing and lead to unexpected results. + + +.. note:: + + As mentioned previously, using plain ASpecD usually does not help you with a rich data model of your dataset, containing all the relevant metadata. However, in the case shown here, it nicely shows the power of ASpecD on itself. Currently, there is no ASpecD-based Python package available for FTIR spectra, at least none the author of the ASpecD framework is aware of. + + +.. literalinclude:: ftir-normalised.yaml + :language: yaml + :linenos: + :caption: Concrete example of a recipe used to plot a comparison of two FTIR spectra normalised to a spectral feature in the region of 1680 to 1750 wavenumbers as a reference. Key here is to specify the region to normalise in *axis units*, making it rather convenient for the user. Furthermore, as mentioned, FTIR spectra are plotted with an inverse *x* axis by convention. Besides that, the standard text file importer is used (with a few extra parameters such as to omit the header lines). Hence, *no metadata* are imported and the axis labels need to be set manually. + + +Result +====== + +.. figure:: ./ftir-normalised.png + + Result of the plotting step in the recipe shown above. The two spectra presented have been normalised to the transmission band between 1680 and 1750 wavenumbers. diff --git a/docs/examples/index.rst b/docs/examples/index.rst index 197248d..d7f57c4 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -13,7 +13,7 @@ For each of the examples, a full recipe is provided ready to be copied and paste .. note:: - For each of the recipes provided so far, real example data can be found in the accompanying `repository on GitHub `_. Therefore, you can download the data and recipe to get first-hand experience with the cwepr package. + For each of the recipes provided so far, real example data can be found in the accompanying `repository on GitHub `_. Therefore, you can download the data and recipe to get first-hand experience with the ASpecD framework. Prerequisites diff --git a/docs/examples/list.rst b/docs/examples/list.rst index 96dceeb..fda493a 100644 --- a/docs/examples/list.rst +++ b/docs/examples/list.rst @@ -9,3 +9,4 @@ Each example covers a specific aspect of working with data, is presented on its :maxdepth: 1 uvvis + ftir From 7ab0b4cb2bd50d53cff9a7975cc7e789daeb3ffd Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 11 Sep 2023 20:33:47 +0200 Subject: [PATCH 10/55] New property device_data in dataset --- VERSION | 2 +- aspecd/dataset.py | 126 +++++++++++++++++++++++++++++++++++++++--- docs/changelog.rst | 14 +++++ tests/test_dataset.py | 33 +++++++++++ 4 files changed, 167 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index ea24850..c8dc557 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev35 +0.9.0.dev36 diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 7162ebb..795eae0 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -1,17 +1,21 @@ """Datasets: units containing data and metadata. -The dataset is one key concept of the ASpecD framework, consisting of the -data as well as the corresponding metadata. Storing metadata in a -structured way is a prerequisite for a semantic understanding within the -routines. Furthermore, a history of every processing, analysis and -annotation step is recorded as well, aiming at a maximum of -reproducibility. This is part of how the ASpecD framework tries to support -good scientific practice. +The dataset is one :doc:`key concept <../concepts>` of the ASpecD framework, +consisting of the data as well as the corresponding metadata. Storing +metadata in a structured way is a prerequisite for a semantic +understanding within the routines. Furthermore, a history of every +processing, analysis and annotation step is recorded as well, aiming at a +maximum of reproducibility. This is part of how the ASpecD framework tries +to support good scientific practice. Therefore, each processing and analysis step of data should always be performed using the respective methods of a dataset, at least as long as it can be performed on a single dataset. + +Types of datasets +================= + Generally, there are two types of datasets: Those containing experimental data and those containing calculated data. Therefore, two corresponding subclasses exist, and packages building upon the ASpecD framework should @@ -20,6 +24,13 @@ * :class:`aspecd.dataset.ExperimentalDataset` * :class:`aspecd.dataset.CalculatedDataset` +Calculated datasets can either be the result of actual simulations or +dummy datasets used for testing purposes. + + +Classes used by the dataset +=========================== + Additional classes used within the dataset that are normally not necessary to implement directly on your own in packages building upon the ASpecD framework, are: @@ -39,6 +50,16 @@ necessary to create axis labels and to make sense of the numerical information. + * :class:`aspecd.dataset.DeviceData` + + Additional data from devices recorded parallel to the actual data. + + The dataset concept (see :class:`aspecd.dataset.Dataset`) rests on the + assumption that there is one particular set of data that can be + regarded as the actual or primary data of the dataset. However, + in many cases, parallel to these actual data, other data are recorded + as well, be it readouts from monitors or alike. + * :class:`aspecd.dataset.DatasetReference` Reference to a dataset. @@ -52,6 +73,9 @@ more) simulations. +History records +=============== + In addition, to handle the history contained within a dataset, there is a series of classes for storing history records: @@ -87,6 +111,10 @@ History record for plots of datasets. + +Module documentation +==================== + """ import copy @@ -133,6 +161,18 @@ class Dataset(aspecd.utils.ToDictMixin): data : :obj:`aspecd.dataset.Data` numeric data and axes + device_data : :class:`dict` + Additional data from devices recorded parallel to the actual data. + + For details and a bit of background, see + :class:`aspecd.dataset.DeviceData`. + + In a real dataset with actual device data, the key typically + identifies the device (type) and the value is of type + :class:`aspecd.dataset.DeviceData`. + + .. versionadded:: 0.9 + metadata : :obj:`aspecd.metadata.DatasetMetadata` hierarchical key-value store of metadata @@ -197,6 +237,7 @@ def __init__(self): super().__init__() self.data = Data() self._origdata = Data() + self.device_data = {} self.metadata = aspecd.metadata.DatasetMetadata() self.history = [] self._history_pointer = -1 @@ -1332,3 +1373,74 @@ class are set accordingly. for key in dict_: if hasattr(self, key): setattr(self, key, dict_[key]) + + +class DeviceData(Data): + """ + Additional data from devices recorded parallel to the actual data. + + The dataset concept (see :class:`aspecd.dataset.Dataset`) rests on the + assumption that there is one particular set of data that can be + regarded as the actual or primary data of the dataset. However, + in many cases, parallel to these actual data, other data are recorded + as well, be it readouts from monitors or alike. + + Usually, these additional data will share one axis with the + primary data of the dataset. However, this is not necessarily the + case. Furthermore, one dataset may contain an arbitrary number of + additional device data entries. + + Technically speaking, :class:`aspecd.dataset.DeviceData` are a special + or extended type of :class:`aspecd.dataset.Data`, *i.e.* a unit + containing both numerical data and corresponding axes. However, + this class extends that with metadata specific for the device the + additional data have been recorded with. Why storing metadata here and + not in the :attr:`aspecd.dataset.Dataset.metadata` property? The + latter is more concerned with an overall description of the + experimental setup in sufficient detail, while the metadata contained + in this class are more device-specific. Potential contents of the + metadata here are internal device IDs, addresses for communication, + and alike. Eventually, the metadata contained herein are those that + can be relevant mainly for debugging purposes or sanity checks of + experiments. + + .. admonition:: Example + + A real example for additional data recorded in spectroscopy comes + from time-resolved EPR (tr-EPR) spectroscopy: Here, you usually + record 2D data as function of magnetic field and time, *i.e.* a + full time profile per magnetic field point. As this is a + non-standard method, often setups are controlled by lab-written + software and allow for monitoring parameters not usually recorded + with commercial setups. In this particular case, this can be the + time stamp and microwave frequency for each individual recorded + time trace, and the `Python trEPR package + `_ not only handles tr-EPR data, but is + capable of dealing with both additional types of data for analysis + purposes. + + + Attributes + ---------- + metadata : :class:`aspecd.metadata.Metadata` + Metadata of the device used to record the additional data + + .. note:: + For actual devices you will probably create dedicated classes + inheriting from :class:`aspecd.metadata.Metadata`. + + calculated : :class:`bool` + Indicator for the origin of the numerical data (calculation or + experiment). + + Default: `False` + + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.metadata = aspecd.metadata.Metadata() + self.calculated = False diff --git a/docs/changelog.rst b/docs/changelog.rst index 41a6a37..f5a7319 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -11,6 +11,14 @@ Version 0.9.0 *Not yet released* +New features +------------ + +* New property :attr:`aspecd.dataset.Dataset.device_data` for storing additional/secondary (monitoring) data. + + * New class :class:`aspecd.dataset.DeviceData` for device data. + + Version 0.8.4 ============= @@ -25,6 +33,12 @@ New features * New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. +Documentation +------------- + +* New example: :doc:`Plotting FTIR spectra normalised to spectral feature ` + + Version 0.8.3 ============= diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 51de683..1729d92 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -77,6 +77,12 @@ def test_package_name_property_is_readonly(self): def test_has_tasks_property(self): self.assertTrue(hasattr(self.dataset, 'tasks')) + def test_has_device_data_property(self): + self.assertTrue(hasattr(self.dataset, 'device_data')) + + def test_device_data_is_dict(self): + self.assertTrue(isinstance(self.dataset.device_data, dict)) + class TestDatasetProcessing(unittest.TestCase): def setUp(self): @@ -1297,3 +1303,30 @@ def test_set_multidimensional_values_fails(self): with self.assertRaisesRegex(IndexError, 'Values need to be ' 'one-dimensional'): self.axis.values = np.zeros([0, 0]) + + +class TestDeviceData(unittest.TestCase): + + def setUp(self): + self.device_data = dataset.DeviceData() + + def test_instantiate_class(self): + pass + + def test_has_data_property(self): + self.assertTrue(hasattr(self.device_data, 'data')) + + def test_data_is_ndarray(self): + self.assertTrue(isinstance(self.device_data.data, np.ndarray)) + + def test_has_axes_property(self): + self.assertTrue(hasattr(self.device_data, 'axes')) + + def test_axes_is_list(self): + self.assertTrue(isinstance(self.device_data.axes, list)) + + def test_has_metadata_property(self): + self.assertTrue(hasattr(self.device_data, 'metadata')) + + def test_calculated_is_false(self): + self.assertFalse(self.device_data.calculated) From 67314fcb9eec0019ef16b6051c6c14a6c5315bd6 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 11 Sep 2023 20:39:52 +0200 Subject: [PATCH 11/55] Refactorings after prospector run --- VERSION | 2 +- aspecd/plotting.py | 49 ++++++++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/VERSION b/VERSION index c8dc557..d5c18f2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev36 +0.9.0.dev37 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 83641b9..d22e1f1 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -3191,6 +3191,7 @@ class AxesProperties(aspecd.utils.Properties): """ + # pylint: disable=too-many-instance-attributes def __init__(self): super().__init__() self.aspect = '' @@ -3236,28 +3237,9 @@ def apply(self, axes=None): for property_, value in self._get_settable_properties().items(): if hasattr(axes, 'set_' + property_): getattr(axes, 'set_' + property_)(value) - if self.xticks is not None: - axes.xaxis.set_major_locator(ticker.FixedLocator(self.xticks)) - if self.yticks is not None: - axes.yaxis.set_major_locator(ticker.FixedLocator(self.yticks)) - if self.xticklabels is not None: - axes.set_xticklabels(self.xticklabels) - if self.yticklabels is not None: - axes.set_yticklabels(self.yticklabels) - for tick in axes.get_xticklabels(): - tick.set_rotation(self.xticklabelangle) - for tick in axes.get_yticklabels(): - tick.set_rotation(self.yticklabelangle) + self._set_axes_ticks(axes) if self.invert: - if isinstance(self.invert, str): - self.invert = [self.invert] - for axis in self.invert: - if axis.lower().startswith('x'): - if not axes.xaxis_inverted(): - axes.invert_xaxis() - if axis.lower().startswith('y'): - if not axes.yaxis_inverted(): - axes.invert_yaxis() + self._invert_axes(axes) def _get_settable_properties(self): """ @@ -3286,6 +3268,31 @@ def _get_settable_properties(self): properties[prop] = all_properties[prop] return properties + def _set_axes_ticks(self, axes): + if self.xticks is not None: + axes.xaxis.set_major_locator(ticker.FixedLocator(self.xticks)) + if self.yticks is not None: + axes.yaxis.set_major_locator(ticker.FixedLocator(self.yticks)) + if self.xticklabels is not None: + axes.set_xticklabels(self.xticklabels) + if self.yticklabels is not None: + axes.set_yticklabels(self.yticklabels) + for tick in axes.get_xticklabels(): + tick.set_rotation(self.xticklabelangle) + for tick in axes.get_yticklabels(): + tick.set_rotation(self.yticklabelangle) + + def _invert_axes(self, axes): + if isinstance(self.invert, str): + self.invert = [self.invert] + for axis in self.invert: + if axis.lower().startswith('x'): + if not axes.xaxis_inverted(): + axes.invert_xaxis() + if axis.lower().startswith('y'): + if not axes.yaxis_inverted(): + axes.invert_yaxis() + class LegendProperties(aspecd.utils.Properties): """ From df104ef5352f80ecf67e39eb53b5c90a4421b98c Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 11 Sep 2023 22:23:13 +0200 Subject: [PATCH 12/55] DeviceDataExtraction for extracting device data from a dataset as a separate dataset --- VERSION | 2 +- aspecd/analysis.py | 114 +++++++++++++++++++++++++++++++++++++++++ docs/changelog.rst | 3 +- tests/test_analysis.py | 49 ++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index d5c18f2..fb693fe 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev37 +0.9.0.dev38 diff --git a/aspecd/analysis.py b/aspecd/analysis.py index 888dfc4..3c32308 100644 --- a/aspecd/analysis.py +++ b/aspecd/analysis.py @@ -113,6 +113,16 @@ Perform linear regression without fitting the intercept on 1D data. Note that this is mathematically different from a polynomial fit of first order. +* :class:`DeviceDataExtraction` + + Extract device data as separate dataset. + + Datasets may contain additional data as device data in + :attr:`aspecd.dataset.Dataset.device_data`. For details, + see :class:`aspecd.dataset.DeviceData`. To further process and analyse + these device data, the most general way is to extract them as + individual dataset and perform all further tasks on it. + Writing own analysis steps ========================== @@ -1951,3 +1961,107 @@ def _perform_task(self): self.result = [self.parameters["offset"], float(results[0])] else: self.result = float(results[0]) + + +class DeviceDataExtraction(SingleAnalysisStep): + """ + Extract device data as separate dataset. + + Datasets may contain additional data as device data in + :attr:`aspecd.dataset.Dataset.device_data`. For details, + see :class:`aspecd.dataset.DeviceData`. To further process and analyse + these device data, the most general way is to extract them as + individual dataset and perform all further tasks on it. + + A reference to the original dataset is stored in + :attr:`aspecd.dataset.Dataset.references`. + + Attributes + ---------- + result : :class:`aspecd.dataset.CalculatedDataset` + Dataset containing the device data. + + The device the data are extracted for is provided by the parameter + ``device``, see below. + + parameters : :class:`dict` + All parameters necessary for this step. + + device : :class:`str` + Name of the device the data should be extracted for. + + Raises a :class:`KeyError` if the device does not exist. + + Default: '' + + Raises + ------ + KeyError + Raised if device is not present in + :attr:`aspecd.dataset.Dataset.device_data` + + + Examples + -------- + For convenience, a series of examples in recipe style (for details of + the recipe-driven data analysis, see :mod:`aspecd.tasks`) is given below + for how to make use of this class. The examples focus each on a single + aspect. + + Suppose you have a dataset that contains device data referenced with + the key "timestamp", and you want to extract those device data and + make them accessible from within the recipe using the name "timestamp" + as well: + + .. code-block:: yaml + + - kind: singleanalysis + type: DeviceDataExtraction + properties: + parameters: + device: timestamp + result: timestamp + + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.description = 'Extract device data' + self.parameters["device"] = '' + + @staticmethod + def applicable(dataset): + """ + Check whether analysis step is applicable to the given dataset. + + Device data extraction is only possible if device data are present. + + Parameters + ---------- + dataset : :class:`aspecd.dataset.Dataset` + Dataset to check + + Returns + ------- + applicable : :class:`bool` + Whether dataset is applicable + + """ + return dataset.device_data + + def _sanitise_parameters(self): + device = self.parameters["device"] + if device not in self.dataset.device_data.keys(): + raise KeyError(f"Device '{device}' not found in dataset") + + def _perform_task(self): + device = self.parameters["device"] + dataset = self.create_dataset() + dataset.data = copy.deepcopy(self.dataset.device_data[device]) + reference = aspecd.dataset.DatasetReference() + reference.from_dataset(self.dataset) + dataset.references.append(reference) + self.result = dataset diff --git a/docs/changelog.rst b/docs/changelog.rst index f5a7319..2d4a952 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,7 @@ New features * New property :attr:`aspecd.dataset.Dataset.device_data` for storing additional/secondary (monitoring) data. * New class :class:`aspecd.dataset.DeviceData` for device data. + * New class :class:`aspecd.analysis.DeviceDataExtraction` for extracting device data from a dataset as a separate dataset. This allows to proceed with the extracted datasets as with any other dataset. Version 0.8.4 @@ -30,7 +31,7 @@ New features * :class:`aspecd.processing.CommonRangeExtraction` works for *N*\ D datasets with arbitrary dimension *N* * Legend title can be set from recipes -* New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. +* New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. Helpful, *e.g.*, for plotting FTIR data without having to resort to explicitly provide descending axis limits. Documentation diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 3d4ede3..66e68c8 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -1046,3 +1046,52 @@ def test_polynomial_compatible_coefficients(self): analysis = self.dataset.analyse(self.analysis) self.assertAlmostEqual(np.pi, analysis.result[0]) self.assertAlmostEqual(2., analysis.result[1]) + + +class TestDeviceDataExtraction(unittest.TestCase): + def setUp(self): + self.analysis = aspecd.analysis.DeviceDataExtraction() + self.dataset = aspecd.dataset.Dataset() + device_data = aspecd.dataset.DeviceData() + device_data.data = np.linspace(1, 50) + device_data.axes[0].values = np.linspace(0.5, 25) + self.dataset.device_data = {'test': device_data} + self.analysis.parameters["device"] = 'test' + + def test_instantiate_class(self): + pass + + def test_has_appropriate_description(self): + self.assertIn('extract device data', + self.analysis.description.lower()) + + def test_analysis_returns_dataset(self): + analysis = self.dataset.analyse(self.analysis) + self.assertTrue(isinstance(analysis.result, aspecd.dataset.Dataset)) + + def test_analysis_with_unknown_device_raises(self): + self.analysis.parameters["device"] = 'nonexisting_device' + with self.assertRaises(KeyError): + self.dataset.analyse(self.analysis) + + def test_analysis_returns_dataset_with_correct_data(self): + analysis = self.dataset.analyse(self.analysis) + self.assertListEqual(list(analysis.result.data.data), + list(self.dataset.device_data["test"].data)) + + def test_returned_dataset_axes_are_not_identical_to_device_axes(self): + analysis = self.dataset.analyse(self.analysis) + # Hint: comparing data rather than axes does not work + self.assertIsNot(analysis.result.data.axes, + self.dataset.device_data["test"].axes) + + def test_returned_dataset_contains_reference_to_original_dataset(self): + self.dataset.id = 'My unique ID' + analysis = self.dataset.analyse(self.analysis) + self.assertEqual(analysis.result.references[0].id, + self.dataset.id) + + def test_analysis_with_dataset_with_no_device_data_raises(self): + self.dataset.device_data = {} + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + self.dataset.analyse(self.analysis) From 9059e393c4a6710fd2cb75a801b5ffaad43021b4 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 11 Sep 2023 22:54:41 +0200 Subject: [PATCH 13/55] Refer to DeviceDataExtraction from Dataset documentation --- VERSION | 2 +- aspecd/dataset.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index fb693fe..9ec572d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev38 +0.9.0.dev39 diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 795eae0..e5e5e92 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -171,6 +171,13 @@ class Dataset(aspecd.utils.ToDictMixin): identifies the device (type) and the value is of type :class:`aspecd.dataset.DeviceData`. + .. note:: + + To further process and analyse these device data, the most + general way is to extract them as individual dataset each and + perform all further tasks on it, respectively. See + :class:`aspecd.analysis.DeviceDataExtraction` for details. + .. versionadded:: 0.9 metadata : :obj:`aspecd.metadata.DatasetMetadata` From c518e7d1845dc45bd77db5492f1951d429f74504 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 12 Sep 2023 00:25:00 +0200 Subject: [PATCH 14/55] First steps towards plotting device data --- VERSION | 2 +- aspecd/plotting.py | 120 ++++++++++++++++++++++++++--------------- tests/test_plotting.py | 35 +++++++++++- 3 files changed, 112 insertions(+), 45 deletions(-) diff --git a/VERSION b/VERSION index 9ec572d..8b51def 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev39 +0.9.0.dev40 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index d22e1f1..91f7b09 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -308,6 +308,18 @@ class Plotter(aspecd.utils.ToDictMixin): Default: False + device_data : :class:`str` or :class:`list` + Name(s) of the device(s) the data should be plotted for. + + Datasets may contain additional data as device data in + :attr:`aspecd.dataset.Dataset.device_data`. For details, + see :class:`aspecd.dataset.DeviceData`. To conveniently plot + those device data instead of the primary data of the dataset, + provide the key(s) to the device(s) the data should be plotted + for. + + Default: '' + properties : :class:`aspecd.plotting.PlotProperties` Properties of the plot, defining its appearance @@ -363,6 +375,9 @@ class Plotter(aspecd.utils.ToDictMixin): .. versionchanged:: 0.6.4 New attribute :attr:`comment` + .. versionchanged:: 0.9 + New parameter ''device_data'' + """ def __init__(self): @@ -372,6 +387,7 @@ def __init__(self): 'show_legend': False, 'show_zero_lines': True, 'tight_layout': False, + 'device_data': '' } self.properties = PlotProperties() self.description = 'Abstract plotting step' @@ -666,6 +682,13 @@ class SinglePlotter(Plotter): dataset : :class:`aspecd.dataset.Dataset` Dataset the plotting should be done for + data : :class:`aspecd.dataset.Data` + Actual data that should be plotted + + Defaults to the primary data of a dataset, but can be the device + data. See the key ``device_data`` of :attr:`Plotter.parameters` for + details. + drawing : :class:`matplotlib.artist.Artist` Actual graphical representation of the data @@ -684,10 +707,11 @@ def __init__(self): super().__init__() self.properties = SinglePlotProperties() self.dataset = None + self.data = None self.drawing = None self.description = 'Abstract plotting step for single dataset' self.__kind__ = 'singleplot' - self._exclude_from_to_dict.extend(['dataset', 'drawing']) + self._exclude_from_to_dict.extend(['dataset', 'data', 'drawing']) def plot(self, dataset=None, from_dataset=False): """Perform the actual plotting on the given dataset. @@ -731,6 +755,7 @@ def plot(self, dataset=None, from_dataset=False): """ self._assign_dataset(dataset) + self._assign_data() self._call_from_dataset(from_dataset) return self.dataset @@ -772,6 +797,15 @@ def _call_from_dataset(self, from_dataset): self._set_axes_labels() self.properties.apply(plotter=self) + def _assign_data(self): + if self.parameters["device_data"]: + device = self.parameters["device_data"] + if device not in self.dataset.device_data: + raise KeyError(f"Device '{device}' not found in dataset.") + self.data = self.dataset.device_data[device] + else: + self.data = self.dataset.data + def _check_applicability(self): if not self.applicable(self.dataset): message = f"{self.name} not applicable to dataset with id " \ @@ -786,8 +820,8 @@ def _set_axes_labels(self): If you ever need to change the handling of your axes labels, override this method in a child class. """ - xlabel = self._create_axis_label_string(self.dataset.data.axes[0]) - ylabel = self._create_axis_label_string(self.dataset.data.axes[1]) + xlabel = self._create_axis_label_string(self.data.axes[0]) + ylabel = self._create_axis_label_string(self.data.axes[1]) self.axes.set_xlabel(xlabel) self.axes.set_ylabel(ylabel) @@ -935,20 +969,20 @@ def _create_plot(self): if not self.properties.drawing.label: self.properties.drawing.label = self.dataset.label if self.parameters['switch_axes']: - self.drawing, = plot_function(self.dataset.data.data, - self.dataset.data.axes[0].values, + self.drawing, = plot_function(self.data.data, + self.data.axes[0].values, label=self.properties.drawing.label) else: - self.drawing, = plot_function(self.dataset.data.axes[0].values, - self.dataset.data.data, + self.drawing, = plot_function(self.data.axes[0].values, + self.data.data, label=self.properties.drawing.label) if self.parameters['tight']: if self.parameters['tight'] in ('x', 'both'): - self.axes.set_xlim([self.dataset.data.axes[0].values.min(), - self.dataset.data.axes[0].values.max()]) + self.axes.set_xlim([self.data.axes[0].values.min(), + self.data.axes[0].values.max()]) if self.parameters['tight'] in ('y', 'both'): - self.axes.set_ylim([self.dataset.data.data.min(), - self.dataset.data.data.max()]) + self.axes.set_ylim([self.data.data.min(), + self.data.data.max()]) def _set_axes_labels(self): super()._set_axes_labels() @@ -1225,24 +1259,24 @@ def _create_plot(self): def _shape_data(self): if self.parameters['switch_axes']: - data = self.dataset.data.data + data = self.data.data else: - data = self.dataset.data.data.T + data = self.data.data.T if self.type == 'imshow': data = np.flipud(data) return data def _get_extent(self): if self.parameters['switch_axes']: - extent = [self.dataset.data.axes[1].values[0], - self.dataset.data.axes[1].values[-1], - self.dataset.data.axes[0].values[0], - self.dataset.data.axes[0].values[-1]] + extent = [self.data.axes[1].values[0], + self.data.axes[1].values[-1], + self.data.axes[0].values[0], + self.data.axes[0].values[-1]] else: - extent = [self.dataset.data.axes[0].values[0], - self.dataset.data.axes[0].values[-1], - self.dataset.data.axes[1].values[0], - self.dataset.data.axes[1].values[-1]] + extent = [self.data.axes[0].values[0], + self.data.axes[0].values[-1], + self.data.axes[1].values[0], + self.data.axes[1].values[-1]] return extent def _set_axes_labels(self): @@ -1262,11 +1296,11 @@ def _set_axes_labels(self): override this method in a child class. """ if self.parameters['switch_axes']: - xlabel = self._create_axis_label_string(self.dataset.data.axes[1]) - ylabel = self._create_axis_label_string(self.dataset.data.axes[0]) + xlabel = self._create_axis_label_string(self.data.axes[1]) + ylabel = self._create_axis_label_string(self.data.axes[0]) else: - xlabel = self._create_axis_label_string(self.dataset.data.axes[0]) - ylabel = self._create_axis_label_string(self.dataset.data.axes[1]) + xlabel = self._create_axis_label_string(self.data.axes[0]) + ylabel = self._create_axis_label_string(self.data.axes[1]) self.axes.set_xlabel(xlabel) self.axes.set_ylabel(ylabel) @@ -1454,28 +1488,28 @@ def applicable(dataset): def _create_plot(self): if self.parameters['offset'] is None: - self.parameters['offset'] = self.dataset.data.data.max() * 1.05 + self.parameters['offset'] = self.data.data.max() * 1.05 yticks = [] if self.parameters['stacking_dimension'] == 0: for idx in range(self.dataset.data.data.shape[0]): # noinspection PyTypeChecker - handle = self.axes.plot(self.dataset.data.axes[1].values, - self.dataset.data.data[idx, :] + handle = self.axes.plot(self.data.axes[1].values, + self.data.data[idx, :] + idx * self.parameters['offset']) self.drawing.append(handle[0]) # noinspection PyTypeChecker yticks.append(idx * self.parameters['offset']) - yticklabels = self.dataset.data.axes[0].values.astype(float) + yticklabels = self.data.axes[0].values.astype(float) else: - for idx in range(self.dataset.data.data.shape[1]): + for idx in range(self.data.data.shape[1]): # noinspection PyTypeChecker - handle = self.axes.plot(self.dataset.data.axes[0].values, - self.dataset.data.data[:, idx] + handle = self.axes.plot(self.data.axes[0].values, + self.data.data[:, idx] + idx * self.parameters['offset']) self.drawing.append(handle[0]) # noinspection PyTypeChecker yticks.append(idx * self.parameters['offset']) - yticklabels = self.dataset.data.axes[1].values.astype(float) + yticklabels = self.data.axes[1].values.astype(float) if self.parameters['ytickcount']: # noinspection PyTypeChecker ytickcount = min(len(self.drawing), self.parameters['ytickcount']) @@ -1491,12 +1525,12 @@ def _create_plot(self): def _handle_tight_settings(self): if self.parameters['tight']: if self.parameters['tight'] in ('x', 'both'): - self.axes.set_xlim([self.dataset.data.axes[0].values.min(), - self.dataset.data.axes[0].values.max()]) + self.axes.set_xlim([self.data.axes[0].values.min(), + self.data.axes[0].values.max()]) if self.parameters['tight'] in ('y', 'both'): if self.parameters['offset'] == 0: - self.axes.set_ylim([self.dataset.data.data.min(), - self.dataset.data.data.max()]) + self.axes.set_ylim([self.data.data.min(), + self.data.data.max()]) def _format_yticklabels(self, yticklabels): if self.parameters['yticklabelformat']: @@ -1522,20 +1556,20 @@ def _set_axes_labels(self): override this method in a child class. """ if self.parameters['stacking_dimension'] == 0: - xlabel = self._create_axis_label_string(self.dataset.data.axes[1]) - ylabel = self._create_axis_label_string(self.dataset.data.axes[0]) + xlabel = self._create_axis_label_string(self.data.axes[1]) + ylabel = self._create_axis_label_string(self.data.axes[0]) else: - xlabel = self._create_axis_label_string(self.dataset.data.axes[0]) - ylabel = self._create_axis_label_string(self.dataset.data.axes[1]) + xlabel = self._create_axis_label_string(self.data.axes[0]) + ylabel = self._create_axis_label_string(self.data.axes[1]) if self.parameters["offset"] == 0: - ylabel = self._create_axis_label_string(self.dataset.data.axes[2]) + ylabel = self._create_axis_label_string(self.data.axes[2]) self.axes.set_xlabel(xlabel) self.axes.set_ylabel(ylabel) def _add_zero_lines(self): if self.parameters['show_zero_lines']: dimension = self.parameters['stacking_dimension'] - for idx in range(self.dataset.data.data.shape[dimension]): + for idx in range(self.data.data.shape[dimension]): # noinspection PyTypeChecker offset = idx * self.parameters['offset'] self.axes.axhline( diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 2600e4c..13087ff 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -215,6 +215,9 @@ def test_plot_sets_tight_layout(self): self.plotter.plot() mock.assert_called() + def test_plot_has_device_data_parameter(self): + self.assertIn('device_data', self.plotter.parameters) + class TestSinglePlotter(unittest.TestCase): def setUp(self): @@ -230,8 +233,11 @@ def test_instantiate_class(self): def test_has_drawing_property(self): self.assertTrue(hasattr(self.plotter, 'drawing')) + def test_has_data_property(self): + self.assertTrue(hasattr(self.plotter, 'data')) + def test_to_dict_does_not_contain_certain_keys(self): - for key in ['dataset', 'drawing']: + for key in ['dataset', 'drawing', 'data']: with self.subTest(key=key): self.assertNotIn(key, self.plotter.to_dict()) @@ -310,6 +316,33 @@ def applicable(dataset): aspecd.exceptions.NotApplicableToDatasetError, message): dataset.plot(plotter) + def test_plot_from_dataset_sets_data(self): + test_dataset = dataset.Dataset() + test_dataset.data.data = np.random.random(5) + plotter = test_dataset.plot(self.plotter) + self.assertListEqual(list(plotter.data.data), + list(test_dataset.data.data)) + + def test_plot_from_dataset_with_device_data_sets_data(self): + test_dataset = dataset.Dataset() + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + test_dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + plotter = test_dataset.plot(self.plotter) + self.assertListEqual(list(plotter.data.data), list(device_data.data)) + + def test_plot_from_dataset_with_non_existent_device_data_raises(self): + test_dataset = dataset.Dataset() + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + test_dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "nonexistent" + with self.assertRaises(KeyError) as context: + test_dataset.plot(self.plotter) + the_exception = context.exception + self.assertIn("not found in dataset", str(the_exception)) + class TestSinglePlotter1D(unittest.TestCase): def setUp(self): From 58e5eb98ef7786725a529f401a1a3a2e89509662 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 12 Sep 2023 23:12:36 +0200 Subject: [PATCH 15/55] Plotters can plot device data --- VERSION | 2 +- aspecd/plotting.py | 41 ++++++++++++------- docs/changelog.rst | 2 + tests/test_plotting.py | 89 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 117 insertions(+), 17 deletions(-) diff --git a/VERSION b/VERSION index 8b51def..19dda8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev40 +0.9.0.dev41 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 91f7b09..6877c10 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -433,8 +433,8 @@ def plot(self): # noinspection PyUnusedLocal @staticmethod - def applicable(dataset): # pylint: disable=unused-argument - """Check whether plot is applicable to the given dataset. + def applicable(data): + """Check whether plot is applicable to the dataset. Returns `True` by default and needs to be implemented in classes inheriting from Plotter according to their needs. @@ -807,7 +807,7 @@ def _assign_data(self): self.data = self.dataset.data def _check_applicability(self): - if not self.applicable(self.dataset): + if not self.applicable(self.data): message = f"{self.name} not applicable to dataset with id " \ f"{self.dataset.id}" raise aspecd.exceptions.NotApplicableToDatasetError(message=message) @@ -993,8 +993,8 @@ def _set_axes_labels(self): self.axes.set_ylabel(old_xlabel) @staticmethod - def applicable(dataset): - """Check whether plot is applicable to the given dataset. + def applicable(data): + """Check whether plot is applicable to the dataset. Checks for the dimension of the data of the dataset, i.e. the :attr:`aspecd.dataset.Data.data` attribute. Returns `True` if data @@ -1006,7 +1006,7 @@ def applicable(dataset): `True` if successful, `False` otherwise. """ - return dataset.data.data.ndim == 1 + return data.data.ndim == 1 class SinglePlotter2D(SinglePlotter): @@ -1217,7 +1217,7 @@ def type(self, plot_type=None): self._type = plot_type @staticmethod - def applicable(dataset): + def applicable(data): """Check whether plot is applicable to the given dataset. Checks for the dimension of the data of the dataset, i.e. the @@ -1230,7 +1230,7 @@ def applicable(dataset): `True` if successful, `False` otherwise. """ - return dataset.data.data.ndim == 2 + return data.data.ndim == 2 def _create_plot(self): """Create actual plot. @@ -1471,8 +1471,8 @@ def __init__(self): self.properties = SinglePlot1DProperties() @staticmethod - def applicable(dataset): - """Check whether plot is applicable to the given dataset. + def applicable(data): + """Check whether plot is applicable to the dataset. Checks for the dimension of the data of the dataset, i.e. the :attr:`aspecd.dataset.Data.data` attribute. Returns `True` if data @@ -1484,7 +1484,7 @@ def applicable(dataset): `True` if successful, `False` otherwise. """ - return dataset.data.data.ndim == 2 + return data.data.ndim == 2 def _create_plot(self): if self.parameters['offset'] is None: @@ -1615,6 +1615,7 @@ def __init__(self): super().__init__() self.properties = MultiPlotProperties() self.datasets = [] + self.data = [] self.description = 'Abstract plotting step for multiple dataset' # noinspection PyTypeChecker self.parameters['axes'] = [aspecd.dataset.Axis(), @@ -1651,6 +1652,7 @@ def plot(self): Raised when no datasets exist to act on """ + self._assign_data() self._check_for_applicability() self._set_drawing_properties() super().plot() @@ -1659,10 +1661,21 @@ def plot(self): # Update/redraw legend after having set properties self._set_legend() + def _assign_data(self): + if self.parameters["device_data"]: + device = self.parameters["device_data"] + for dataset in self.datasets: + if device not in dataset.device_data: + raise KeyError(f"Device '{device}' not found in dataset.") + self.data.append(dataset.device_data[device]) + else: + for dataset in self.datasets: + self.data.append(dataset.data) + def _check_for_applicability(self): if not self.datasets: raise aspecd.exceptions.MissingDatasetError - if not all(self.applicable(dataset) for dataset in self.datasets): + if not all(self.applicable(data) for data in self.data): raise aspecd.exceptions.NotApplicableToDatasetError( f'{self.name} not applicable to one or more datasets') @@ -1881,7 +1894,7 @@ def type(self, plot_type=None): self._type = plot_type @staticmethod - def applicable(dataset): + def applicable(data): """Check whether plot is applicable to the given dataset. Checks for the dimension of the data of the dataset, i.e. the @@ -1894,7 +1907,7 @@ def applicable(dataset): `True` if successful, `False` otherwise. """ - return dataset.data.data.ndim == 1 + return data.data.ndim == 1 def _create_plot(self): """Actual drawing of datasets""" diff --git a/docs/changelog.rst b/docs/changelog.rst index 2d4a952..66c1e48 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -19,6 +19,8 @@ New features * New class :class:`aspecd.dataset.DeviceData` for device data. * New class :class:`aspecd.analysis.DeviceDataExtraction` for extracting device data from a dataset as a separate dataset. This allows to proceed with the extracted datasets as with any other dataset. +* New parameter ``device_data`` in :class:`aspecd.plotting.Plotter` for plotting device data rather than primary data of a dataset/datasets + Version 0.8.4 ============= diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 13087ff..57c963f 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -293,7 +293,7 @@ def test_plot_checks_applicability(self): class MyPlotter(aspecd.plotting.SinglePlotter): @staticmethod - def applicable(dataset): + def applicable(data): return False dataset = aspecd.dataset.Dataset() @@ -305,7 +305,7 @@ def test_plot_check_applicability_prints_helpful_message(self): class MyPlotter(aspecd.plotting.SinglePlotter): @staticmethod - def applicable(dataset): + def applicable(data): return False dataset = aspecd.dataset.Dataset() @@ -477,6 +477,27 @@ def test_switch_axes_actually_switches_axes(self): list(self.plotter.axes.lines[0].get_xdata()) ) + def test_plot_device_data_plots_device_data(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + self.dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + self.plotter.dataset = self.dataset + self.plotter.plot() + self.assertListEqual( + list(device_data.data), + list(self.plotter.axes.lines[0].get_ydata()) + ) + + def test_plot_device_data_checks_applicability(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random([5, 5]) + self.dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + self.plotter.dataset = self.dataset + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + self.plotter.plot() + class TestSinglePlotter2D(unittest.TestCase): def setUp(self): @@ -716,6 +737,15 @@ def test_contourf_plot_with_contour_lines_sets_correct_colors(self): contour_color = list(contour_lines[0].get_facecolor()[0]) self.assertListEqual([0., 0., 0., 1.], contour_color) + def test_plot_device_data_checks_applicability_of_device_data(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + test_dataset = aspecd.dataset.CalculatedDataset() + test_dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + plotter = test_dataset.plot(self.plotter) + class TestSinglePlotter2DStacked(unittest.TestCase): def setUp(self): @@ -980,6 +1010,27 @@ def test_set_maximum_of_yticks_does_not_exceed_lines(self): plotter = test_dataset.plot(self.plotter) self.assertEqual(5, len(plotter.axes.get_yticks())) + def test_plot_device_data_plots_device_data(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random([5, 5]) + test_dataset = aspecd.dataset.CalculatedDataset() + test_dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + plotter = test_dataset.plot(self.plotter) + self.assertListEqual( + list(device_data.data[:, 0]), + list(plotter.axes.lines[0].get_ydata()) + ) + + def test_plot_device_data_checks_applicability_of_device_data(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + test_dataset = aspecd.dataset.CalculatedDataset() + test_dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + plotter = test_dataset.plot(self.plotter) + class TestMultiPlotter(unittest.TestCase): def setUp(self): @@ -998,6 +1049,12 @@ def test_has_datasets_property(self): def test_datasets_property_is_list(self): self.assertTrue(isinstance(self.plotter.datasets, list)) + def test_has_data_property(self): + self.assertTrue(hasattr(self.plotter, 'data')) + + def test_data_property_is_list(self): + self.assertTrue(isinstance(self.plotter.data, list)) + def test_plot_without_datasets_raises(self): with self.assertRaises(aspecd.exceptions.MissingDatasetError): self.plotter.plot() @@ -1134,6 +1191,25 @@ def test_to_dict_does_not_contain_certain_keys(self): with self.subTest(key=key): self.assertNotIn(key, self.plotter.to_dict()) + def test_plot_sets_data(self): + test_dataset = dataset.Dataset() + test_dataset.data.data = np.random.random(5) + self.plotter.datasets.append(test_dataset) + self.plotter.plot() + self.assertListEqual(list(self.plotter.data[0].data), + list(test_dataset.data.data)) + + def test_plot_with_device_data_sets_data(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + test_dataset = dataset.Dataset() + test_dataset.device_data["test"] = device_data + self.plotter.datasets.append(test_dataset) + self.plotter.parameters["device_data"] = "test" + self.plotter.plot() + self.assertListEqual(list(self.plotter.data[0].data), + list(device_data.data)) + class TestMultiPlotter1D(unittest.TestCase): def setUp(self): @@ -1282,6 +1358,15 @@ def test_tight_sets_correct_axes_limits_with_switched_axes(self): list(self.plotter.ax.get_xlim())) self.assertListEqual([0, 4], list(self.plotter.ax.get_ylim())) + def test_plot_device_data_checks_applicability(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random([5, 5]) + self.dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + self.plotter.datasets.append(self.dataset) + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + self.plotter.plot() + class TestMultiPlotter1DStacked(unittest.TestCase): def setUp(self): From 5813bdfbb84e489af9a29635097eeee6a7acef38 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 12 Sep 2023 23:14:58 +0200 Subject: [PATCH 16/55] Silence pylint --- VERSION | 2 +- aspecd/plotting.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 19dda8c..b4053ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev41 +0.9.0.dev42 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 6877c10..eea6378 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -433,7 +433,7 @@ def plot(self): # noinspection PyUnusedLocal @staticmethod - def applicable(data): + def applicable(data): # pylint: disable=unused-argument """Check whether plot is applicable to the dataset. Returns `True` by default and needs to be implemented in classes From 772afca52f378d7001edaba5a24b564bbb56a81e Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 12 Sep 2023 23:24:55 +0200 Subject: [PATCH 17/55] Update roadmap; note important changes in plotters in changelog --- VERSION | 2 +- docs/changelog.rst | 5 +++++ docs/roadmap.rst | 2 -- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index b4053ed..a54f79b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev42 +0.9.0.dev43 diff --git a/docs/changelog.rst b/docs/changelog.rst index 66c1e48..1642d1d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,11 @@ New features * New parameter ``device_data`` in :class:`aspecd.plotting.Plotter` for plotting device data rather than primary data of a dataset/datasets +Changes +------- + +* Plotters can now handle device data instead of the primary data of a dataset (see above). This means, however, that instead of accessing ``self.dataset.data`` (or ``self.datasets[#].data``), plotters need to access ``self.data.data`` (or ``self.data[#].data``) instead. **Authors of derived packages should update their plotters accordingly.** + Version 0.8.4 ============= diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 9dfebb4..9ee9086 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -42,8 +42,6 @@ For next releases * Datasets - * Handling of additional axes/parameters that are logged parallel to a measurement, requiring extension of the dataset model. - * Add export tasks to dataset tasks * Recipe-driven data analysis: From 58add84c4c6f34f274e0d0ccf5a3969cbe34c405 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Thu, 14 Sep 2023 20:45:57 +0200 Subject: [PATCH 18/55] DeviceData have own metadata --- VERSION | 2 +- aspecd/dataset.py | 9 +++++---- aspecd/metadata.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_dataset.py | 4 ++++ tests/test_metadata.py | 20 ++++++++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index a54f79b..1ea609e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev43 +0.9.0.dev44 diff --git a/aspecd/dataset.py b/aspecd/dataset.py index e5e5e92..01607e5 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -1429,12 +1429,13 @@ class DeviceData(Data): Attributes ---------- - metadata : :class:`aspecd.metadata.Metadata` + metadata : :class:`aspecd.metadata.Device` Metadata of the device used to record the additional data .. note:: - For actual devices you will probably create dedicated classes - inheriting from :class:`aspecd.metadata.Metadata`. + For actual devices you will want to create dedicated classes + inheriting from :class:`aspecd.metadata.Device` and extending + the available attributes. calculated : :class:`bool` Indicator for the origin of the numerical data (calculation or @@ -1449,5 +1450,5 @@ class DeviceData(Data): def __init__(self): super().__init__() - self.metadata = aspecd.metadata.Metadata() + self.metadata = aspecd.metadata.Device() self.calculated = False diff --git a/aspecd/metadata.py b/aspecd/metadata.py index 92cd5f2..839e77d 100644 --- a/aspecd/metadata.py +++ b/aspecd/metadata.py @@ -637,6 +637,45 @@ def __init__(self, dict_=None): super().__init__(dict_=dict_) +class Device(Metadata): + """ + Information on the device contributing device data. + + The dataset concept (see :class:`aspecd.dataset.Dataset`) rests on the + assumption that there is one particular set of data that can be + regarded as the actual or primary data of the dataset. However, + in many cases, parallel to these actual data, other data are recorded + as well, be it readouts from monitors or alike. + + This class contains the metadata of the corresponding devices whose + data are of type :class:`aspecd.dataset.DeviceData`. That class + contains an attribute :attr:`aspecd.dataset.DeviceData.metadata`. + + .. note:: + You will usually need to implement derived classes for concrete + devices, as this class only contains a minimum set of attributes. + + + Attributes + ---------- + label : :class:`str` + Label of the device + + Parameters + ---------- + dict_ : :class:`dict` + Dictionary containing fields corresponding to attributes of the class + + + .. versionadded:: 0.9 + + """ + + def __init__(self, dict_=None): + self.label = '' + super().__init__(dict_=dict_) + + class DatasetMetadata(aspecd.utils.ToDictMixin): """ Metadata for dataset. diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 1729d92..20771bb 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -1328,5 +1328,9 @@ def test_axes_is_list(self): def test_has_metadata_property(self): self.assertTrue(hasattr(self.device_data, 'metadata')) + def test_metadata_property_is_device_metadata(self): + self.assertIsInstance(self.device_data.metadata, + aspecd.metadata.Device) + def test_calculated_is_false(self): self.assertFalse(self.device_data.calculated) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 1500b8e..96fd789 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -399,6 +399,26 @@ def test_set_properties_from_dict(self): self.assertEqual(getattr(self.calculation, key), dict_[key]) +class TestDevice(unittest.TestCase): + def setUp(self): + self.device = metadata.Device() + + def test_instantiate_class(self): + pass + + def test_instantiate_properties_from_dict(self): + dict_ = {"label": "foo"} + device = metadata.Device(dict_) + for key in dict_: + self.assertEqual(getattr(device, key), dict_[key]) + + def test_set_properties_from_dict(self): + dict_ = {"label": "foo"} + self.device.from_dict(dict_) + for key in dict_: + self.assertEqual(getattr(self.device, key), dict_[key]) + + class TestExperimentalDatasetMetadata(unittest.TestCase): def setUp(self): self.dataset_metadata = metadata.ExperimentalDatasetMetadata() From 1cde22f89916df9014139c7f1a79ae99d5cac375 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Thu, 14 Sep 2023 23:51:09 +0200 Subject: [PATCH 19/55] MultiDeviceDataPlotter1D --- VERSION | 2 +- docs/changelog.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1ea609e..78579e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev44 +0.9.0.dev45 diff --git a/docs/changelog.rst b/docs/changelog.rst index 1642d1d..922a398 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,7 @@ New features * New class :class:`aspecd.dataset.DeviceData` for device data. * New class :class:`aspecd.analysis.DeviceDataExtraction` for extracting device data from a dataset as a separate dataset. This allows to proceed with the extracted datasets as with any other dataset. + * New class :class:`aspecd.plotting.MultiDeviceDataPlotter1D` for plotting multiple device data of a single dataset. * New parameter ``device_data`` in :class:`aspecd.plotting.Plotter` for plotting device data rather than primary data of a dataset/datasets From 7b76e32f0ddcda4269c384345fca21f7301952e0 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 15 Sep 2023 21:26:18 +0200 Subject: [PATCH 20/55] MultiDeviceDataPlotter1D --- VERSION | 2 +- aspecd/plotting.py | 278 ++++++++++++++++++++++++++++++++++++++++- tests/test_plotting.py | 225 +++++++++++++++++++++++++++++++-- 3 files changed, 496 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index 78579e7..61a553d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev45 +0.9.0.dev46 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index eea6378..9bf783d 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -118,6 +118,11 @@ Composite plotter for single datasets, allowing to plot different views of one and the same datasets by using existing plotters for single datasets. +* :class:`aspecd.plotting.MultiDeviceDataPlotter1D` + + Basic line plots for multiple device data of a single dataset, allowing to + plot a series of line-type plots, including (semi)log plots + Concrete plotters for multiple datasets --------------------------------------- @@ -318,6 +323,10 @@ class Plotter(aspecd.utils.ToDictMixin): provide the key(s) to the device(s) the data should be plotted for. + Will be a string (*i.e.* data of a single device) in all cases + except of specific plotters for plotting data of multiple + devices. + Default: '' properties : :class:`aspecd.plotting.PlotProperties` @@ -1578,6 +1587,256 @@ def _add_zero_lines(self): zorder=1) +class MultiDeviceDataPlotter1D(SinglePlotter1D): + """1D plots of multiple device data of a single dataset. + + Convenience class taking care of 1D plots of multiple device data + of a single dataset. The type of plot can be set in its + :attr:`SinglePlotter1D.type` attribute. Allowed types are stored in the + :attr:`SinglePlotter1D.allowed_types` attribute. + + Quite a number of properties for figure, axes, and line can be set + using the :attr:`MultiDeviceDataPlotter1D.properties` attribute. + For details, see the documentation of its respective class, + :class:`MultiPlot1DProperties`. + + To perform the plot, call the :meth:`plot` method of the dataset the plot + should be performed for, and provide a reference to the actual plotter + object to it. + + Attributes + ---------- + dataset : :class:`aspecd.dataset.Dataset` + Dataset the plotting should be done for + + data : :class:`list` + Actual data that should be plotted. + + List of :class:`aspecd.dataset.DeviceData` objects corresponding + to the device data selected using the parameter ``device_data``. + + drawing : :class:`list` + List of :obj:`matplotlib.artist.Artist` objects, one for each of the + actual lines of the plot + + properties : :class:`aspecd.plotting.MultiPlot1DProperties` + Properties of the plot, defining its appearance + + For the properties that can be set this way, see the documentation + of the :class:`aspecd.plotting.MultiPlot1DProperties` class. + + parameters : :class:`dict` + All parameters necessary for the plot, implicit and explicit + + The following keys exist, in addition to those of the superclass: + + axes : :class:`list` + List of objects of class :class:`aspecd.dataset.Axis` + + There is two ways of setting axes labels: The user may provide + the information required here. Alternatively, if no such + information is provided, the axes of each dataset are checked + for consistency, and if they are found to be identical, + this information is used. + + tight: :class:`str` + Whether to set the axes limits tight to the data + + Possible values: 'x', 'y', 'both' + + Default: '' + + switch_axes : :class:`bool` + Whether to switch *x* and *y* axes + + Normally, the first axis is used as *x* axis, and the second + as *y* axis. Sometimes, switching this assignment is + necessary or convenient. + + Default: False + + + Raises + ------ + TypeError + Raised when wrong plot type is set + + Examples + -------- + For convenience, a series of examples in recipe style (for details of + the recipe-driven data analysis, see :mod:`aspecd.tasks`) is given below + for how to make use of this class. Of course, all parameters settable + for the superclasses can be set as well. The examples focus each on a + single aspect. + + In the simplest case, just invoke the plotter with default values. + Note, however, that in any case, you need to provide a list of devices + whose data should be plotted: + + .. code-block:: yaml + + - kind: singleplot + type: MultiDeviceDataPlotter1D + properties: + parameters: + device_data: + - device_1 + - device_2 + filename: output.pdf + + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.description = '1D plotting step for multiple device data' + self.data = [] + self.drawing = [] + self.parameters['axes'] = [aspecd.dataset.Axis(), + aspecd.dataset.Axis()] + self.parameters['tight'] = '' + self.parameters['switch_axes'] = False + self.properties = MultiPlot1DProperties() + + @property + def drawings(self): + """Alias for drawing property. + + As the plotter uses :class:`MultiPlot1DProperties` as + :attr:`properties`, this alias is necessary to apply the drawings + settings. + + """ + return self.drawing + + @staticmethod + def applicable(data): + """Check whether plot is applicable to the given dataset. + + Checks for the dimension of the data of the dataset, i.e. the + :attr:`aspecd.dataset.Data.data` attribute. Returns `True` if data + are one-dimensional, and `False` otherwise. + + Returns + ------- + applicable : :class:`bool` + `True` if successful, `False` otherwise. + + """ + return data.data.ndim == 1 + + def _check_applicability(self): + for data in self.data: + if not self.applicable(data): + message = f"{self.name} not applicable to dataset with id " \ + f"{self.dataset.id}" + raise aspecd.exceptions.NotApplicableToDatasetError( + message=message) + + def _assign_data(self): + if not self.parameters["device_data"]: + raise KeyError("No device data provided") + if not isinstance(self.parameters["device_data"], list): + # noinspection PyTypedDict + self.parameters["device_data"] = [self.parameters["device_data"]] + devices = self.parameters["device_data"] + for device in devices: + if device not in self.dataset.device_data: + raise KeyError(f"Device '{device}' not found in dataset.") + self.data.append(self.dataset.device_data[device]) + + def _create_plot(self): + self._set_drawing_properties() + plot_function = getattr(self.axes, self.type) + for idx, data in enumerate(self.data): + label = data.metadata.label or self.parameters['device_data'][idx] + if not self.properties.drawings[idx].label: + self.properties.drawings[idx].label = label + if self.parameters['switch_axes']: + drawing, = plot_function(data.data, + data.axes[0].values, + label=label) + else: + drawing, = plot_function(data.axes[0].values, + data.data, + label=label) + self.drawing.append(drawing) + if self.parameters['tight']: + axes_limits = [min(data.axes[0].values.min() + for data in self.data), + max(data.axes[0].values.max() + for data in self.data)] + data_limits = [min(data.data.min() + for data in self.data), + max(data.data.max() + for data in self.data)] + if self.parameters['tight'] in ('x', 'both'): + if self.parameters['switch_axes']: + self.axes.set_xlim(data_limits) + else: + self.axes.set_xlim(axes_limits) + if self.parameters['tight'] in ('y', 'both'): + if self.parameters['switch_axes']: + self.axes.set_ylim(axes_limits) + else: + self.axes.set_ylim(data_limits) + + def _set_drawing_properties(self): + for _ in range(len(self.properties.drawings), len(self.data)): + self.properties.add_drawing() + + # noinspection PyUnresolvedReferences + def _set_axes_labels(self): + """Set axes labels from axes. + + This method is called automatically by :meth:`plot`. + + There is two ways of setting axes labels: The user may provide the + information required in the "axes" key of the + :attr:`aspecd.plotting.Plotter.parameters` property containing a + list of :obj:`aspecd.dataset.Axis` objects. Alternatively, + if no such information is provided, the axes of each dataset are + checked for consistency, and if they are found to be identical, + this information is used. + + If you ever need to change the handling of your axes labels, + override this method in a child class. + """ + xquantities = [data.axes[0].quantity for data in self.data] + xunits = [data.axes[0].unit for data in self.data] + yquantities = [data.axes[1].quantity for data in self.data] + yunits = [data.axes[1].unit for data in self.data] + if self.parameters['axes'][0].quantity: + xlabel = \ + self._create_axis_label_string(self.parameters['axes'][0]) + elif aspecd.utils.all_equal(xquantities) and \ + aspecd.utils.all_equal(xunits): + xlabel = self._create_axis_label_string(self.data[0].axes[0]) + elif self.properties.axes.xlabel: + xlabel = self.properties.axes.xlabel + else: + xlabel = '' + if self.parameters['axes'][1].quantity: + ylabel = \ + self._create_axis_label_string(self.parameters['axes'][1]) + elif aspecd.utils.all_equal(yquantities) and \ + aspecd.utils.all_equal(yunits): + ylabel = self._create_axis_label_string(self.data[0].axes[1]) + elif self.properties.axes.ylabel: + ylabel = self.properties.axes.ylabel + else: + ylabel = '' + self.axes.set_xlabel(xlabel) + self.axes.set_ylabel(ylabel) + if self.parameters['switch_axes']: + old_xlabel = self.axes.get_xlabel() + old_ylabel = self.axes.get_ylabel() + self.axes.set_xlabel(old_ylabel) + self.axes.set_ylabel(old_xlabel) + + class MultiPlotter(Plotter): """Base class for plots of multiple datasets. @@ -1599,9 +1858,26 @@ class MultiPlotter(Plotter): ---------- properties : :class:`aspecd.plotting.MultiPlotProperties` Properties of the plot, defining its appearance + datasets : :class:`list` List of dataset the plotting should be done for + parameters : :class:`dict` + All parameters necessary for the plot, implicit and explicit + + The following keys exist, in addition to the keys inherited from the + superclass: + + axes : :class:`list` + List of objects of class :class:`aspecd.dataset.Axis` + + There is two ways of setting axes labels: The user may provide + the information required here. Alternatively, if no such + information is provided, the axes of each dataset are checked + for consistency, and if they are found to be identical, + this information is used. + + Raises ------ aspecd.exceptions.MissingDatasetError @@ -1621,7 +1897,7 @@ def __init__(self): self.parameters['axes'] = [aspecd.dataset.Axis(), aspecd.dataset.Axis()] self.__kind__ = 'multiplot' - self._exclude_from_to_dict.extend(['datasets', 'drawings']) + self._exclude_from_to_dict.extend(['datasets', 'drawings', 'data']) def plot(self): """Perform the actual plotting on the given list of datasets. diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 57c963f..d3b9544 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1029,7 +1029,218 @@ def test_plot_device_data_checks_applicability_of_device_data(self): test_dataset.device_data["test"] = device_data self.plotter.parameters["device_data"] = "test" with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): - plotter = test_dataset.plot(self.plotter) + test_dataset.plot(self.plotter) + + +class TestMultiDeviceDataPlotter1D(unittest.TestCase): + def setUp(self): + self.plotter = plotting.MultiDeviceDataPlotter1D() + self.filename = 'foo.pdf' + self.dataset = None + + def tearDown(self): + if self.plotter.fig: + plt.close(self.plotter.fig) + if os.path.exists(self.filename): + os.remove(self.filename) + + def create_dataset(self, devices=('device1', 'device2')): + self.dataset = aspecd.dataset.CalculatedDataset() + xdata = np.linspace(0, 2*np.pi, 501) + for device in devices: + device_data = dataset.DeviceData() + device_data.data = np.sin(xdata + np.random.random()) + device_data.axes[0].quantity = 'time' + device_data.axes[0].unit = 'ms' + device_data.axes[1].quantity = 'intensity' + device_data.axes[1].unit = 'a.u.' + self.dataset.device_data[device] = device_data + self.plotter.parameters["device_data"] = list(devices) + + def test_instantiate_class(self): + pass + + def test_class_has_sensible_description(self): + self.assertIn('multiple device data', self.plotter.description) + + def test_plot_with_2D_device_data_raises(self): + test_dataset = aspecd.dataset.CalculatedDataset() + device_data = dataset.DeviceData() + device_data.data = np.random.random([5, 5]) + test_dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + test_dataset.plot(self.plotter) + + def test_plot_with_one_2D_device_data_raises(self): + test_dataset = aspecd.dataset.CalculatedDataset() + device_data1 = dataset.DeviceData() + device_data1.data = np.random.random(5) + test_dataset.device_data["test1"] = device_data1 + device_data2 = dataset.DeviceData() + device_data2.data = np.random.random([5, 5]) + test_dataset.device_data["test2"] = device_data2 + self.plotter.parameters["device_data"] = ["test1", "test2"] + with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): + test_dataset.plot(self.plotter) + + def test_plot_without_datasets_raises(self): + with self.assertRaises(aspecd.exceptions.MissingDatasetError): + self.plotter.plot() + + def test_plot_without_device_data_raises(self): + test_dataset = aspecd.dataset.CalculatedDataset() + with self.assertRaises(KeyError) as context: + test_dataset.plot(self.plotter) + the_exception = context.exception + self.assertIn("No device data provided", str(the_exception)) + + def test_plot_with_device_data_sets_data(self): + self.create_dataset() + self.dataset.plot(self.plotter) + self.assertEqual(len(self.plotter.parameters["device_data"]), + len(self.plotter.data)) + + def test_parameters_have_axes_key(self): + self.assertIn('axes', self.plotter.parameters) + + def test_parameters_axes_is_list_of_axes_objects(self): + self.assertTrue(isinstance(self.plotter.parameters['axes'], list)) + self.assertTrue(self.plotter.parameters['axes']) + for axis in self.plotter.parameters['axes']: + self.assertTrue(isinstance(axis, dataset.Axis)) + + def test_plot_with_axes_in_parameters_sets_axes_labels(self): + self.plotter.parameters['axes'][0].quantity = 'foo' + self.plotter.parameters['axes'][0].unit = 'bar' + self.plotter.parameters['axes'][1].quantity = 'foo2' + self.plotter.parameters['axes'][1].unit = 'bar2' + xlabel = '$' + self.plotter.parameters['axes'][0].quantity + \ + '$' + ' / ' + self.plotter.parameters['axes'][0].unit + ylabel = '$' + self.plotter.parameters['axes'][1].quantity + \ + '$' + ' / ' + self.plotter.parameters['axes'][1].unit + self.create_dataset() + plotter = self.dataset.plot(self.plotter) + self.assertEqual(xlabel, plotter.axes.get_xlabel()) + self.assertEqual(ylabel, plotter.axes.get_ylabel()) + + def test_plot_with_datasets_with_identical_axes_sets_axes_labels(self): + self.create_dataset() + device = self.plotter.parameters['device_data'][0] + device_data = self.dataset.device_data[device] + xlabel = '$' + device_data.axes[0].quantity + '$' + ' / ' + \ + device_data.axes[0].unit + ylabel = '$' + device_data.axes[1].quantity + '$' + ' / ' + \ + device_data.axes[1].unit + plotter = self.dataset.plot(self.plotter) + self.assertEqual(xlabel, plotter.axes.get_xlabel()) + self.assertEqual(ylabel, plotter.axes.get_ylabel()) + + def test_axes_properties_set_axes_labels(self): + self.create_dataset() + self.plotter.properties.axes.xlabel = 'foo' + self.plotter.properties.axes.ylabel = 'bar' + plotter = self.dataset.plot(self.plotter) + self.assertEqual(plotter.properties.axes.xlabel, + plotter.axes.get_xlabel()) + self.assertEqual(plotter.properties.axes.ylabel, + plotter.axes.get_ylabel()) + + def test_plot_consists_of_correct_number_of_lines(self): + self.create_dataset() + plotter = self.dataset.plot(self.plotter) + self.assertGreaterEqual(len(plotter.axes.get_lines()), + len(self.plotter.parameters["device_data"])) + + def test_plot_sets_drawings(self): + self.create_dataset() + self.dataset.plot(self.plotter) + self.assertEqual(len(self.plotter.parameters["device_data"]), + len(self.plotter.drawing)) + + def test_plot_adds_drawing_properties(self): + self.create_dataset() + self.dataset.plot(self.plotter) + self.assertEqual(len(self.plotter.data), + len(self.plotter.properties.drawings)) + + def test_plot_sets_label_from_device_metadata(self): + self.create_dataset() + devices = list(self.dataset.device_data.keys()) + self.dataset.device_data[devices[0]].metadata.label = "device foo" + self.dataset.device_data[devices[1]].metadata.label = "device bar" + plotter = self.dataset.plot(self.plotter) + for idx, device in enumerate(devices): + self.assertEqual( + self.dataset.device_data[device].metadata.label, + plotter.drawing[idx].get_label() + ) + + def test_plot_sets_label_from_device_key_if_no_metadata_label(self): + self.create_dataset() + devices = list(self.dataset.device_data.keys()) + plotter = self.dataset.plot(self.plotter) + for idx, device in enumerate(devices): + self.assertEqual(device, plotter.drawing[idx].get_label()) + + def test_axes_tight_x_sets_xlim_to_data_limits(self): + self.create_dataset() + self.plotter.parameters['tight'] = 'x' + plotter = self.dataset.plot(self.plotter) + self.assertAlmostEqual(plotter.data[0].axes[0].values[0], + plotter.axes.get_xlim()[0], + 4) + + def test_axes_tight_y_sets_ylim_to_data_limits(self): + self.create_dataset() + self.plotter.parameters['tight'] = 'y' + plotter = self.dataset.plot(self.plotter) + self.assertAlmostEqual(plotter.data[0].data.min(), + plotter.axes.get_ylim()[0], + 4) + + def test_axes_tight_both_sets_xlim_and_ylim_to_data_limits(self): + self.create_dataset() + self.plotter.parameters['tight'] = 'both' + plotter = self.dataset.plot(self.plotter) + self.assertAlmostEqual(plotter.data[0].axes[0].values[0], + plotter.axes.get_xlim()[0], + 4) + self.assertAlmostEqual(plotter.data[0].data.min(), + plotter.axes.get_ylim()[0], + 4) + + def test_has_switch_axes_parameter(self): + self.assertTrue('switch_axes' in self.plotter.parameters) + + def test_switch_axes_sets_correct_axes_labels(self): + self.create_dataset() + self.plotter.parameters['switch_axes'] = True + plotter = self.dataset.plot(self.plotter) + device_data = self.dataset.device_data[self.plotter.parameters[ + "device_data"][0]] + self.assertIn(device_data.axes[1].quantity, plotter.ax.get_xlabel()) + self.assertIn(device_data.axes[0].quantity, plotter.ax.get_ylabel()) + + def test_switch_axes_actually_switches_axes(self): + self.create_dataset() + self.plotter.parameters['switch_axes'] = True + plotter = self.dataset.plot(self.plotter) + device_data = self.dataset.device_data[self.plotter.parameters[ + "device_data"][0]] + self.assertListEqual( + list(device_data.data), + list(plotter.axes.lines[0].get_xdata()) + ) + + def test_plot_sets_correct_line_color(self): + self.create_dataset() + color1 = '#abcdef' + color2 = '#012345' + dict_ = {'drawings': [{'color': color1}, {'color': color2}]} + self.plotter.properties.from_dict(dict_) + plotter = self.dataset.plot(self.plotter) + self.assertEqual(color1, plotter.drawing[0].get_color()) class TestMultiPlotter(unittest.TestCase): @@ -1055,6 +1266,11 @@ def test_has_data_property(self): def test_data_property_is_list(self): self.assertTrue(isinstance(self.plotter.data, list)) + def test_to_dict_does_not_contain_certain_keys(self): + for key in ['datasets', 'drawings', 'data']: + with self.subTest(key=key): + self.assertNotIn(key, self.plotter.to_dict()) + def test_plot_without_datasets_raises(self): with self.assertRaises(aspecd.exceptions.MissingDatasetError): self.plotter.plot() @@ -1107,7 +1323,7 @@ def test_plot_with_datasets_with_identical_axes_sets_axes_labels(self): self.assertEqual(xlabel, self.plotter.axes.get_xlabel()) self.assertEqual(ylabel, self.plotter.axes.get_ylabel()) - def test_plot_with_datasets_with_identical_quantity_sets_axes_labels(self): + def test_plot_with_datasets_w_identical_quantity_sets_axes_labels(self): test_dataset0 = dataset.Dataset() test_dataset0.data.axes[0].quantity = 'foo' test_dataset0.data.axes[0].unit = '' @@ -1186,11 +1402,6 @@ def applicable(dataset): aspecd.exceptions.NotApplicableToDatasetError, message): plotter.plot() - def test_to_dict_does_not_contain_certain_keys(self): - for key in ['datasets', 'drawings']: - with self.subTest(key=key): - self.assertNotIn(key, self.plotter.to_dict()) - def test_plot_sets_data(self): test_dataset = dataset.Dataset() test_dataset.data.data = np.random.random(5) From d4a572554efd7dc85fd17856ec2cd089fd9d1bef Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 15 Sep 2023 21:47:01 +0200 Subject: [PATCH 21/55] Update documentation --- VERSION | 2 +- aspecd/plotting.py | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 61a553d..20f30b1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev46 +0.9.0.dev47 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 9bf783d..28866cb 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -918,6 +918,21 @@ class SinglePlotter1D(SinglePlotter): switch_axes: true filename: output.pdf + If the dataset contains additional device data, and you want to plot + data of a single device rather than the primary data of the dataset ( + and the device data are 1D), provide the name of the device (*i.e.*, + the key the device data are stored in the dataset). Assuming the + device data are stored as ``timestamp`` in the dataset: + + .. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + parameters: + device_data: timestamp + filename: output.pdf + .. versionchanged:: 0.7 New parameter ``switch_axes`` @@ -1684,6 +1699,66 @@ class MultiDeviceDataPlotter1D(SinglePlotter1D): - device_2 filename: output.pdf + Often, it is convenient to have a legend to know for which devices the + data are plotted: + + .. code-block:: yaml + + - kind: singleplot + type: MultiDeviceDataPlotter1D + properties: + parameters: + device_data: + - device_1 + - device_2 + show_legend: True + filename: output.pdf + + Here, it is interesting to note what labels will be used: Usually, + the data for each device will have the attribute + :class:`aspecd.metadata.Device.label` set, and if so, this label will + be used as label in the legend. If this attribute is not set, and you + do not provide an alternative label in the + :attr:`MultiPlot1DProperties.drawing` attribute, the key the device + data are known within the dataset will be used in the legend. + + To explicitly set (or override) the labels of your device data: + + .. code-block:: yaml + + - kind: singleplot + type: MultiDeviceDataPlotter1D + properties: + parameters: + device_data: + - device_1 + - device_2 + show_legend: True + properties: + drawings: + - label: first device + - label: second device + filename: output.pdf + + As axes are only labelled in case the axes of all devices are + compatible, there may be situations where you want to set the axes + properties explicitly: + + .. code-block:: yaml + + - kind: singleplot + type: MultiDeviceDataPlotter1D + properties: + parameters: + device_data: + - device_1 + - device_2 + axes: + - quantity: time + unit: s + - quantity: intensity + unit: a.u. + filename: output.pdf .. versionadded:: 0.9 From 26561dc3dcdb5b99428a6b26bde39c579685f58c Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 15:56:07 +0200 Subject: [PATCH 22/55] Serving recipes logs messages from all ASpecD modules --- VERSION | 2 +- aspecd/__init__.py | 5 +++++ aspecd/tasks.py | 8 ++++---- docs/changelog.rst | 3 +++ tests/test_tasks.py | 10 +++++++++- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/VERSION b/VERSION index 20f30b1..192fe20 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev47 +0.9.0.dev48 diff --git a/aspecd/__init__.py b/aspecd/__init__.py index 7cc82b0..b04762e 100644 --- a/aspecd/__init__.py +++ b/aspecd/__init__.py @@ -41,3 +41,8 @@ Exceptions for the ASpecD package """ +import logging + + +package_logger = logging.getLogger(__name__) +package_logger.addHandler(logging.NullHandler()) diff --git a/aspecd/tasks.py b/aspecd/tasks.py index 1333756..7d2c0f6 100644 --- a/aspecd/tasks.py +++ b/aspecd/tasks.py @@ -897,7 +897,7 @@ class from the :mod:`aspecd.processing` module. The same is true for import aspecd.plotting import aspecd.system import aspecd.utils - +from aspecd import package_logger logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -4348,13 +4348,13 @@ def serve(): if not args.quiet: if args.verbose: - logger.setLevel(logging.DEBUG) + package_logger.setLevel(logging.DEBUG) else: - logger.setLevel(logging.INFO) + package_logger.setLevel(logging.INFO) handler = logging.StreamHandler(stream=sys.stdout) formatter = logging.Formatter('%(levelname)s - %(message)s') handler.setFormatter(formatter) - logger.addHandler(handler) + package_logger.addHandler(handler) chef_de_service = ChefDeService() try: chef_de_service.serve(recipe_filename=args.recipe) diff --git a/docs/changelog.rst b/docs/changelog.rst index 922a398..a25b004 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,6 +28,9 @@ Changes * Plotters can now handle device data instead of the primary data of a dataset (see above). This means, however, that instead of accessing ``self.dataset.data`` (or ``self.datasets[#].data``), plotters need to access ``self.data.data`` (or ``self.data[#].data``) instead. **Authors of derived packages should update their plotters accordingly.** +* Serving recipes logs messages from all ASpecD modules, not only from the :mod:`aspecd.tasks` module. + + Version 0.8.4 ============= diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 3c8092a..58fac89 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -3687,7 +3687,7 @@ def test_written_history_with_numpy_array_can_be_used_as_recipe(self): os.remove(history_filename) -@unittest.skip +#@unittest.skip class TestServe(unittest.TestCase): def setUp(self): @@ -3773,6 +3773,14 @@ def test_call_with_quiet_option_does_not_print_log_info(self): capture_output=True, text=True) self.assertNotIn('Import dataset', result.stdout) + def test_logging_from_other_module_than_tasks(self): + tabulate_task = {'kind': 'tabulate', 'type': 'Table'} + self.recipe_dict['tasks'].append(tabulate_task) + self.create_recipe() + result = subprocess.run(["serve", self.recipe_filename], + capture_output=True, text=True) + self.assertIn('WARNING', result.stdout) + def test_serve_catches_exceptions(self): self.recipe_dict["tasks"][0]["kind"] = "foo" self.create_recipe() From 353b6cca3968735748d07ca73de4ec222cd0c6c5 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 16:58:24 +0200 Subject: [PATCH 23/55] aspecd.utils.get_logger function for retrieving loggers Updated documentation on writing own applications based on the ASpecD framework accordingly. --- VERSION | 2 +- aspecd/__init__.py | 5 --- aspecd/tasks.py | 3 +- aspecd/utils.py | 80 +++++++++++++++++++++++++++++++++++++++++++ docs/applications.rst | 16 +++++++++ docs/changelog.rst | 2 ++ tests/test_tasks.py | 2 +- tests/test_utils.py | 21 ++++++++++++ 8 files changed, 123 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 192fe20..587c68e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev48 +0.9.0.dev49 diff --git a/aspecd/__init__.py b/aspecd/__init__.py index b04762e..7cc82b0 100644 --- a/aspecd/__init__.py +++ b/aspecd/__init__.py @@ -41,8 +41,3 @@ Exceptions for the ASpecD package """ -import logging - - -package_logger = logging.getLogger(__name__) -package_logger.addHandler(logging.NullHandler()) diff --git a/aspecd/tasks.py b/aspecd/tasks.py index 7d2c0f6..a9cc7cc 100644 --- a/aspecd/tasks.py +++ b/aspecd/tasks.py @@ -897,7 +897,7 @@ class from the :mod:`aspecd.processing` module. The same is true for import aspecd.plotting import aspecd.system import aspecd.utils -from aspecd import package_logger + logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -4346,6 +4346,7 @@ def serve(): help="don't show any output") args = parser.parse_args() + package_logger = aspecd.utils.get_logger() if not args.quiet: if args.verbose: package_logger.setLevel(logging.DEBUG) diff --git a/aspecd/utils.py b/aspecd/utils.py index 453f991..8258f7c 100644 --- a/aspecd/utils.py +++ b/aspecd/utils.py @@ -11,6 +11,7 @@ import hashlib import importlib import inspect +import logging import os import pkgutil import re @@ -1025,3 +1026,82 @@ def change_working_dir(path=''): # pylint: disable=redefined-outer-name yield finally: os.chdir(oldpwd) + + +def get_logger(name=''): + """ + Get logger object for a given module. + + Logging in libraries is slightly different from standard logging with + respect to the handler attached to the logger. The general advice from + the `Python Logging HOWTO `_ is to explicitly add the + :class:`logging.Nullhandler` as a handler. + + Additionally, if you want to add loggers for a library that inherits + from/builds upon a framework, in this particular case a library/package + built atop the ASpecD framework, you want to have loggers being children + of the framework logger in order to have the framework catch the log + messages your library/package creates. + + Why does this matter, particularly for the ASpecD framework? If you want + to have your log messages in a package based on the ASpecD framework appear + when using :doc:`recipe-driven data analysis `, you need to + have your package loggers to be in the hierarchy below the root logger + of the ASpecD framework. For convenience and in order not to make any + informed guesses on how the ASpecD framework root logger is named, + simply use this function to create the loggers in the modules of your + package. + + Parameters + ---------- + name : :class:`str` + Name of the module to get the logger for. + + Usually, this will be set to ``__name__``, as this returns the + current module (including the package name and separated with a + ``.`` if present). + + Returns + ------- + logger : :class:`logging.Logger` + Logger object for the module + + The logger will have a :class:`logging.NullHandler` handler + attached, in line with the advice from the `Python Logging HOWTO + `_. + + + Examples + -------- + To add a logger to a module in your library/package based on the ASpecD + framework, add something like this to the top of your module: + + .. code-block:: + + import aspecd.utils + + + logger = aspecd.utils.get_logger(__name__) + + The important aspect here is to use ``__name__`` as the name of the + logger. The reason is simple: ``__name__`` gets automatically expanded + to the name of the current module, with the name of all parent + modules/the package prefixed, using dot notation. The resulting logger + will be situated in the hierarchy below the ``aspecd`` package logger. + Suppose you have added the above lines to the module + ``mypackage.processing`` in the ``processing`` module of your package + ``mypackage``. This will result in a logger ``aspecd.mypackage.processing``. + + + .. versionadded:: 0.9 + + """ + if not name: + name = package_name() + else: + name = ".".join([package_name(), name]) + logger = logging.getLogger(name=name) + logger.addHandler(logging.NullHandler()) + return logger diff --git a/docs/applications.rst b/docs/applications.rst index aaae781..900b289 100644 --- a/docs/applications.rst +++ b/docs/applications.rst @@ -348,6 +348,22 @@ Of course, this only gets you started, and until now, we have not tested a singl Of course, you need to import numpy in this case, for having the data assigned random numbers in this case, but you will anyway often use numpy for your actual processing. Furthermore, you can see here why not defining the standard parameters for a processing step in the ``setUp`` method is quite helpful, as it helps you see in your tests explicitly how to actually use your class. Using ``assertRaisesRegex`` is a good idea to enforce sensible error messages of the exceptions raised. For more details, you may have a look into the test classes of the ASpecD framework for now. +Logging +======= + +Logging gets quite important as soon as you implement more complex functionality. In order to have your logging play seamlessly with the ASpecD framework, and particularly with :doc:`recipe-driven data analysis `, you need to have the loggers for your modules to be below the root logger of the ASpecD framework. Hence, whenever you need a logger in a module of your package, create one using the :func:`aspecd.utils.get_logger` function. This is most conveniently done by adding the following lines of code towards the top of your module: + +.. code-block:: + + import aspecd.utils + + + logger = aspecd.utils.get_logger(__name__) + + +This will both, create a logger that is located below the root logger of the ASpecD framework in the logger hierarchy, and with a :class:`logging.NullHandler` handler added, as advocated for in the `Python Logging HOWTO `_. + + What's next? ============ diff --git a/docs/changelog.rst b/docs/changelog.rst index a25b004..832e85a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -22,6 +22,8 @@ New features * New parameter ``device_data`` in :class:`aspecd.plotting.Plotter` for plotting device data rather than primary data of a dataset/datasets +* New function :func:`aspecd.utils.get_logger` to get a logger object for a given module with the logger within the hierarchy of the ASpecD root logger. Important for packages derived from the ASpecD framework in order to get their log messages being captured, *e.g.* during recipe-driven data analysis. + Changes ------- diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 58fac89..33e4757 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -3687,7 +3687,7 @@ def test_written_history_with_numpy_array_can_be_used_as_recipe(self): os.remove(history_filename) -#@unittest.skip +@unittest.skip class TestServe(unittest.TestCase): def setUp(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index 2da949a..8d85dfb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ import collections import copy import datetime +import logging import os import shutil import unittest @@ -940,3 +941,23 @@ def test_change_working_dir_returns_to_original_dir(self): with utils.change_working_dir('..'): pass self.assertEqual(oldpwd, os.getcwd()) + + +class TestGetLogger(unittest.TestCase): + + def test_get_logger_returns_logger(self): + logger = utils.get_logger() + self.assertIsInstance(logger, logging.Logger) + + def test_get_logger_without_argument_returns_package_logger(self): + logger = utils.get_logger() + self.assertEqual(utils.package_name(), logger.name) + + def test_get_logger_with_argument_returns_child_logger(self): + logger = utils.get_logger(__name__) + name = ".".join([utils.package_name(), __name__]) + self.assertEqual(name, logger.name) + + def test_get_logger_returns_logger_with_null_handler(self): + logger = utils.get_logger(__name__) + self.assertIsInstance(logger.handlers[0], logging.NullHandler) From 416009caf3ea5375f559d12420ac94a11e7f5327 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 17:25:52 +0200 Subject: [PATCH 24/55] DatasetImporterFactory logs warning if no concrete importer could be found --- VERSION | 2 +- aspecd/io.py | 6 ++++++ docs/changelog.rst | 2 ++ tests/test_io.py | 6 ++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 587c68e..7aabc15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev49 +0.9.0.dev50 diff --git a/aspecd/io.py b/aspecd/io.py index 39e4167..b8e9490 100644 --- a/aspecd/io.py +++ b/aspecd/io.py @@ -291,6 +291,7 @@ class may look like the following:: """ import copy import io +import logging import os import tempfile import zipfile @@ -302,6 +303,9 @@ class may look like the following:: import aspecd.metadata import aspecd.utils +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + class DatasetImporter: """Base class for dataset importer. @@ -575,6 +579,8 @@ def _get_aspecd_importer(self): return AsdfImporter(source=self.source) if file_extension == '.txt': return TxtImporter(source=self.source) + logger.warning('No importer found. Using default importer. This may ' + 'result in downstream problems.') return DatasetImporter(source=self.source) diff --git a/docs/changelog.rst b/docs/changelog.rst index 832e85a..3d5c8ef 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -32,6 +32,8 @@ Changes * Serving recipes logs messages from all ASpecD modules, not only from the :mod:`aspecd.tasks` module. +* :class:`aspecd.io.DatasetImporterFactory` logs warning if no concrete importer could be found for a given dataset, as this will usually result in (sometimes hard to detect) downstream problems. + Version 0.8.4 ============= diff --git a/tests/test_io.py b/tests/test_io.py index 38d9ad2..91cbe30 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -147,6 +147,12 @@ def test_get_importer_with_parameters_sets_parameters(self): parameters=parameters) self.assertDictEqual(parameters, importer.parameters) + def test_returning_abstract_importer_logs_warning(self): + with self.assertLogs(__package__, level='WARNING') as captured: + self.factory.get_importer(source=self.source) + self.assertEqual(len(captured.records), 1) + self.assertIn('default importer', captured.output[0].lower()) + class TestRecipeImporter(unittest.TestCase): def setUp(self): From c99cdd9c407eca22545802c7e38b9319d72d0e0a Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 18:52:46 +0200 Subject: [PATCH 25/55] DatasetExporter adds history record to Dataset.tasks --- VERSION | 2 +- aspecd/dataset.py | 2 + aspecd/history.py | 141 ++++++++++++++++++++++++++++++++++++++++-- aspecd/io.py | 23 +++++++ docs/changelog.rst | 2 + docs/roadmap.rst | 10 --- tests/test_dataset.py | 13 ++++ tests/test_history.py | 95 +++++++++++++++++++++++++++- tests/test_io.py | 12 +++- tests/test_tasks.py | 2 +- 10 files changed, 281 insertions(+), 21 deletions(-) diff --git a/VERSION b/VERSION index 7aabc15..ea871c4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev50 +0.9.0.dev51 diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 01607e5..b81b9ef 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -670,6 +670,8 @@ def export_to(self, exporter=None): if not exporter: raise aspecd.exceptions.MissingExporterError("No exporter provided") exporter.export_from(self) + exporter_record = exporter.create_history_record() + self._append_task(kind='export', task=exporter_record) def add_reference(self, dataset=None): """ diff --git a/aspecd/history.py b/aspecd/history.py index 4a4c11e..8cd2e69 100644 --- a/aspecd/history.py +++ b/aspecd/history.py @@ -2,7 +2,7 @@ Reproducibility is an essential aspect of good scientific practice. In the context of data processing and analysis, this means that each processing -step performed on data (of a dataset) should be stored in an reproducible +step performed on data (of a dataset) should be stored in a reproducible way and preferably in a consistent format. To be of actual use, an entry of the history needs to contain all @@ -110,7 +110,7 @@ class ProcessingStepRecord(aspecd.utils.ToDictMixin): .. note:: Each history entry in a dataset stores the processing as a - :class:`aspecd.processing.ProcessingStepRecord`, even in applications + :class:`aspecd.history.ProcessingStepRecord`, even in applications inheriting from the ASpecD framework. Hence, subclassing of this class should normally not be necessary. @@ -387,7 +387,7 @@ class exists in the current installation of the application. Another is .. note:: Each analyses entry in a dataset stores the analysis step as a - :class:`aspecd.analysis.SingleAnalysisStepRecord`, even in applications + :class:`aspecd.history.SingleAnalysisStepRecord`, even in applications inheriting from the ASpecD framework. Hence, subclassing of this class should normally not be necessary. @@ -476,11 +476,11 @@ class AnnotationRecord(aspecd.utils.ToDictMixin): annotations in their analyses for which no corresponding annotation class exists in the current installation of the application. Another is to not have an infinite recursion of datasets, as the dataset is stored - in an :obj:`aspecd.analysis.SingleAnalysisStep` object. + in an :obj:`aspecd.annotation.Annotation` object. .. note:: Each annotation entry in a dataset stores the annotation as a - :class:`aspecd.annotation.AnnotationRecord`, even in applications + :class:`aspecd.history.AnnotationRecord`, even in applications inheriting from the ASpecD framework. Hence, subclassing of this class should normally not be necessary. @@ -912,3 +912,134 @@ class TableHistoryRecord(HistoryRecord): def __init__(self, table=None, package=''): super().__init__(package=package) self.table = TableRecord(table) + + +class DatasetExporterRecord(aspecd.utils.ToDictMixin): + """Base class for dataset exporter records stored in the dataset tasks. + + The tasks list of a :class:`aspecd.dataset.Dataset` should *not* contain + references to :class:`aspecd.io.DatasetExporter` objects, but rather + records that contain all necessary information to create the respective + objects inherited from :class:`aspecd.io.DatasetExporter`. One + reason for this is simply that we want to import datasets containing + export tasks for which no corresponding exporter class exists in the + current installation of the application. + + Attributes + ---------- + class_name : :class:`str` + Fully qualified name of the class of the corresponding annotation + + target : :class:`str` + specifier of the target the data and metadata were written to + + comment : :class:`str` + User-supplied comment describing intent, purpose, reason, ... + + Parameters + ---------- + exporter : :class:`aspecd.io.DatasetExporter` + Dataset exporter the record should be created for. + + Raises + ------ + TypeError + Raised when no exporter exists to act on + + + .. versionadded:: 0.9 + + """ + + def __init__(self, exporter=None): + super().__init__() + self.class_name = '' + self.target = '' + self.comment = '' + self._attributes_to_copy = ['target', 'comment'] + if exporter: + self.from_exporter(exporter) + + def create_exporter(self): + """Create a dataset exporter object from the parameters stored. + + Returns + ------- + exporter : :class:`aspecd.io.DatasetExporter` + actual exporter object that can be used for exporting the dataset + + """ + exporter = aspecd.utils.object_from_class_name(self.class_name) + for attribute in self._attributes_to_copy: + setattr(self, attribute, getattr(exporter, attribute)) + return exporter + + def from_exporter(self, exporter=None): + """Obtain information from dataset exporter. + + Parameters + ---------- + exporter : :obj:`aspecd.io.DatasetExporter` + Dataset exporter object to obtain information from + + Raises + ------ + TypeError + Raised if no exporter is provided. + + """ + if not exporter: + raise TypeError('from_exporter needs a DatasetExporter object') + self.class_name = aspecd.utils.full_class_name(exporter) + for attribute in self._attributes_to_copy: + setattr(self, attribute, getattr(exporter, attribute)) + + def from_dict(self, dict_=None): + """ + Set properties from dictionary. + + Only parameters in the dictionary that are valid properties of the + class are set accordingly. + + Parameters + ---------- + dict_ : :class:`dict` + Dictionary containing properties to set + + """ + for key, value in dict_.items(): + if hasattr(self, key): + setattr(self, key, value) + + +class DatasetExporterHistoryRecord(HistoryRecord): + """History record for dataset exporters created from datasets. + + Attributes + ---------- + exporter : :class:`aspecd.io.DatasetExporter` + Dataset exporter the history is saved for + + package : :class:`str` + Name of package the history record gets recorded for + + Prerequisite for reproducibility, gets stored in the + :attr:`aspecd.dataset.HistoryRecord.sysinfo` attribute. + Will usually be provided automatically by the dataset. + + Parameters + ---------- + exporter : :class:`aspecd.table.DatasetExporter` + Dataset exporter the history is saved for + + package : :class:`str` + Name of package the history record gets recorded for + + + .. versionadded:: 0.9 + + """ + + def __init__(self, exporter=None, package=''): + super().__init__(package) + self.exporter = DatasetExporterRecord(exporter=exporter) diff --git a/aspecd/io.py b/aspecd/io.py index b8e9490..0300321 100644 --- a/aspecd/io.py +++ b/aspecd/io.py @@ -300,6 +300,7 @@ class may look like the following:: import numpy as np import aspecd.exceptions +import aspecd.history import aspecd.metadata import aspecd.utils @@ -679,6 +680,28 @@ def _export(self): """ + def create_history_record(self): + """ + Create history record to be added to the dataset. + + Usually, this method gets called from within the + :meth:`aspecd.dataset.export_to` method of the + :class:`aspecd.dataset.Dataset` class and ensures the history of + each processing step to get written properly. + + Returns + ------- + history_record : :class:`aspecd.history.DatasetExporterHistoryRecord` + history record for export step + + + .. versionadded:: 0.9 + + """ + history_record = aspecd.history.DatasetExporterHistoryRecord( + package=self.dataset.package_name, exporter=self) + return history_record + class RecipeImporter: """Base class for recipe importer. diff --git a/docs/changelog.rst b/docs/changelog.rst index 3d5c8ef..fc546bb 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -34,6 +34,8 @@ Changes * :class:`aspecd.io.DatasetImporterFactory` logs warning if no concrete importer could be found for a given dataset, as this will usually result in (sometimes hard to detect) downstream problems. +* :class:`aspecd.io.DatasetExporter` adds a history record to :attr:`aspecd.dataset.Dataset.tasks`. + Version 0.8.4 ============= diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 9ee9086..58a5ae7 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -8,16 +8,6 @@ A few ideas how to develop the project further, currently a list as a reminder f For next releases ================= -* Logging - - * Add loggers from other modules (than task) and derived packages - - Probably this means to switch to package-wide logging and documenting that derived packages need to log to the ASpecD logger as well. - -* Usability - - * Importer/ImporterFactory should issue a warning if no dataset could be loaded, rather than silently continuing, as this often leads to downstream problems and exceptions thrown. (Requires changes in the way logging is currently done.) - * Plotting * Colorbar for 2D plotter diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 20771bb..de35a0b 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -588,6 +588,19 @@ def test_export_without_exporter_raises(self): with self.assertRaises(aspecd.exceptions.MissingExporterError): self.dataset.export_to() + def test_export_adds_task(self): + self.dataset.export_to(self.exporter) + self.assertNotEqual(self.dataset.tasks, []) + + def test_added_task_has_kind_export(self): + self.dataset.export_to(self.exporter) + self.assertEqual(self.dataset.tasks[0]['kind'], 'export') + + def test_added_task_has_exporter_history_record(self): + self.dataset.export_to(self.exporter) + self.assertIsInstance(self.dataset.tasks[0]['task'], + aspecd.history.DatasetExporterHistoryRecord) + class TestDatasetToDict(unittest.TestCase): def setUp(self): diff --git a/tests/test_history.py b/tests/test_history.py index 6fd3691..293d1a6 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -8,7 +8,7 @@ import aspecd.history import aspecd.system import aspecd.utils -from aspecd import processing, dataset, analysis, plotting, table +from aspecd import processing, dataset, analysis, plotting, table, io class TestHistoryRecord(unittest.TestCase): @@ -628,9 +628,98 @@ def test_instantiate_class_with_package_name_sets_sysinfo(self): package="numpy") self.assertTrue("numpy" in table_.sysinfo.packages.keys()) - def test_has_annotation_property(self): + def test_has_table_property(self): self.assertTrue(hasattr(self.table_record, 'table')) - def test_annotation_is_annotation_record(self): + def test_table_is_table_record(self): self.assertTrue(isinstance(self.table_record.table, aspecd.history.TableRecord)) + + +class TestDatasetExporterRecord(unittest.TestCase): + def setUp(self): + self.exporter = io.DatasetExporter() + self.exporter_record = \ + aspecd.history.DatasetExporterRecord(self.exporter) + + def test_instantiate_class(self): + pass + + def test_instantiate_class_name_from_exporter(self): + exporter_record = aspecd.history.DatasetExporterRecord(self.exporter) + self.assertEqual(exporter_record.class_name, + aspecd.utils.full_class_name(self.exporter)) + + def test_copy_attributes_from_exporter(self): + self.exporter.target = 'foobar' + self.exporter.comment = 'Lorem ipsum' + exporter_record = aspecd.history.DatasetExporterRecord(self.exporter) + self.assertEqual(exporter_record.target, self.exporter.target) + self.assertEqual(exporter_record.comment, self.exporter.comment) + + def test_has_from_exporter_method(self): + self.assertTrue(hasattr(self.exporter_record, 'from_exporter')) + self.assertTrue(callable(self.exporter_record.from_exporter)) + + def test_has_create_exporter_method(self): + self.assertTrue(hasattr(self.exporter_record, 'create_exporter')) + self.assertTrue(callable(self.exporter_record.create_exporter)) + + def test_create_exporter_returns_exporter_object(self): + test_object = self.exporter_record.create_exporter() + self.assertTrue(isinstance(test_object, aspecd.io.DatasetExporter)) + + def test_exporter_object_has_correct_attributes(self): + self.exporter_record.target = 'foo' + self.exporter_record.comment = 'Lorem ipsum' + test_object = self.exporter_record.create_exporter() + self.assertEqual(self.exporter_record.target, test_object.target) + self.assertEqual(self.exporter_record.comment, test_object.comment) + + def test_has_to_dict_method(self): + self.assertTrue(hasattr(self.exporter_record, 'to_dict')) + self.assertTrue(callable(self.exporter_record.to_dict)) + + def test_from_dict(self): + orig_dict = self.exporter_record.to_dict() + orig_dict["target"] = 'foo' + new_exporter_record = aspecd.history.DatasetExporterRecord() + new_exporter_record.from_dict(orig_dict) + self.assertDictEqual(orig_dict, + new_exporter_record.to_dict()) + + def test_from_exporter_without_exporter_raises(self): + with self.assertRaisesRegex(TypeError, 'needs a DatasetExporter'): + self.exporter_record.from_exporter() + + +class TestDatasetExporterHistoryRecord(unittest.TestCase): + def setUp(self): + self.exporter = io.DatasetExporter() + self.exporter_record = \ + aspecd.history.DatasetExporterHistoryRecord(exporter=self.exporter) + + def test_instantiate_class(self): + pass + + def test_instantiate_class_with_package_name(self): + aspecd.history.DatasetExporterHistoryRecord( + exporter=self.exporter, package="numpy") + + def test_instantiate_class_with_package_name_sets_sysinfo(self): + exporter = aspecd.history.DatasetExporterHistoryRecord( + exporter=self.exporter, package="numpy") + self.assertTrue("numpy" in exporter.sysinfo.packages.keys()) + + def test_has_exporter_property(self): + self.assertTrue(hasattr(self.exporter_record, 'exporter')) + + def test_exporter_is_exporter_record(self): + self.assertTrue(isinstance(self.exporter_record.exporter, + aspecd.history.DatasetExporterRecord)) + + def test_exporter_has_correct_attributes(self): + self.exporter.target = "foo" + exporter = aspecd.history.DatasetExporterHistoryRecord( + exporter=self.exporter) + self.assertEqual(self.exporter.target, exporter.exporter.target) \ No newline at end of file diff --git a/tests/test_io.py b/tests/test_io.py index 91cbe30..3ccab2a 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -8,7 +8,7 @@ import aspecd.exceptions import aspecd.processing -from aspecd import io, dataset, tasks, utils +from aspecd import io, dataset, tasks, utils, history class TestDatasetImporter(unittest.TestCase): @@ -92,6 +92,16 @@ def test_export_from_with_dataset_sets_dataset(self): self.exporter.export_from(test_dataset) self.assertIs(self.exporter.dataset, test_dataset) + def test_has_create_history_record_method(self): + self.assertTrue(hasattr(self.exporter, 'create_history_record')) + self.assertTrue(callable(self.exporter.create_history_record)) + + def test_create_history_record_returns_history_record(self): + self.exporter.dataset = aspecd.dataset.Dataset() + history_record = self.exporter.create_history_record() + self.assertTrue(isinstance(history_record, + aspecd.history.DatasetExporterHistoryRecord)) + class TestDatasetImporterFactory(unittest.TestCase): def setUp(self): diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 33e4757..5b9b3fb 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -3668,7 +3668,7 @@ def test_serve_issues_warning_if_told_to_not_write_history(self): self.chef_de_service.serve(recipe_filename=self.recipe_filename) self.assertIn('No history has been written. This is considered bad ' 'practice in terms of reproducible research', - cm.output[0]) + cm.output[1]) def test_written_history_can_be_used_as_recipe(self): self.create_recipe() From 549385a366bfc01409b71faf1a2a54d3f01884f7 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 19:09:46 +0200 Subject: [PATCH 26/55] Cleanup roadmap --- VERSION | 2 +- docs/roadmap.rst | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/VERSION b/VERSION index ea871c4..bc064bf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev51 +0.9.0.dev52 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 58a5ae7..b389aef 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -30,10 +30,6 @@ For next releases * :class:`aspecd.processing.BaselineCorrection` with ``fit_area`` definable as axis range, and arbitrary parts of the axis (*e.g.*, in the middle of a dataset or with separate fit areas) -* Datasets - - * Add export tasks to dataset tasks - * Recipe-driven data analysis: * Better handling of automatically generated filenames for saving plots and reports: unique filenames; using the label rather than the source (id) of the dataset From 821d6d08fbaf271a67fcee29082794919068acb4 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 21:03:28 +0200 Subject: [PATCH 27/55] Restructure changelog; update documentation 0.8.4 => 0.9; documentation of history records moved to history module --- VERSION | 2 +- aspecd/dataset.py | 79 +++++++++++++++++++++++--------------------- aspecd/history.py | 68 ++++++++++++++++++++++++++++++++++++++ aspecd/plotting.py | 4 +-- aspecd/processing.py | 2 +- docs/changelog.rst | 34 +++++++++---------- 6 files changed, 131 insertions(+), 58 deletions(-) diff --git a/VERSION b/VERSION index bc064bf..090ad2e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev52 +0.9.0.dev53 diff --git a/aspecd/dataset.py b/aspecd/dataset.py index b81b9ef..00ba179 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -73,43 +73,48 @@ more) simulations. -History records -=============== - -In addition, to handle the history contained within a dataset, there is a -series of classes for storing history records: - - * :class:`aspecd.dataset.HistoryRecord` - - Generic base class for all kinds of history records. - - For all classes operating on datasets, such as - :class:`aspecd.processing.SingleProcessingStep`, - :class:`aspecd.analysis.SingleAnalysisStep` and others, there exist at - least two "representations": (i) the generic one not (necessarily) tied - to any concrete dataset, thus portable, and (ii) a concrete one having - operated on a dataset and thus being accompanied with information about - who has done what when how to what dataset. - - For this second type, a history class derived from - :class:`aspecd.dataset.HistoryRecord` gets used, and it is this second type - that is stored inside the Dataset object. - - * :class:`aspecd.dataset.ProcessingHistoryRecord` - - History record for processing steps on datasets. - - * :class:`aspecd.dataset.AnalysisHistoryRecord` - - History record for analysis steps on datasets. - - * :class:`aspecd.dataset.AnnotationHistoryRecord` - - History record for annotations of datasets. - - * :class:`aspecd.dataset.PlotHistoryRecord` - - History record for plots of datasets. +Device data +=========== + +The dataset concept (see :class:`aspecd.dataset.Dataset`) rests on the +assumption that there is one particular set of data that can be +regarded as the actual or primary data of the dataset. However, +in many cases, parallel to these actual data, other data are recorded +as well, be it readouts from monitors or alike. + +Usually, these additional data will share one axis with the +primary data of the dataset. However, this is not necessarily the +case. Furthermore, one dataset may contain an arbitrary number of +additional device data entries. + +Technically speaking, :class:`aspecd.dataset.DeviceData` are a special +or extended type of :class:`aspecd.dataset.Data`, *i.e.* a unit +containing both numerical data and corresponding axes. However, +this class extends that with metadata specific for the device the +additional data have been recorded with. Why storing metadata here and +not in the :attr:`aspecd.dataset.Dataset.metadata` property? The +latter is more concerned with an overall description of the +experimental setup in sufficient detail, while the metadata contained +in this class are more device-specific. Potential contents of the +metadata here are internal device IDs, addresses for communication, +and alike. Eventually, the metadata contained herein are those that +can be relevant mainly for debugging purposes or sanity checks of +experiments. + +.. admonition:: Example + + A real example for additional data recorded in spectroscopy comes + from time-resolved EPR (tr-EPR) spectroscopy: Here, you usually + record 2D data as function of magnetic field and time, *i.e.* a + full time profile per magnetic field point. As this is a + non-standard method, often setups are controlled by lab-written + software and allow for monitoring parameters not usually recorded + with commercial setups. In this particular case, this can be the + time stamp and microwave frequency for each individual recorded + time trace, and the `Python trEPR package + `_ not only handles tr-EPR data, but is + capable of dealing with both additional types of data for analysis + purposes. Module documentation diff --git a/aspecd/history.py b/aspecd/history.py index 8cd2e69..81cc4cb 100644 --- a/aspecd/history.py +++ b/aspecd/history.py @@ -13,6 +13,74 @@ information about the operating system used, the name of the operator, and the date the processing step has been performed. +Furthermore, for reproducing data processing and analysis tasks in a +modular fashion, there should be a way to create the actual objects for +operating on a dataset from the history records stored in the history of +the dataset. Furthermore, it is important to not store the actual objects +(or a representation) of the individual tasks. Firstly, this would easily +result in an infinite regress, as the dataset is referenced from within +the task objects, and secondly, importing a stored dataset with a history +would not work if during import the actual objects of the individual tasks +need to be restored and the class has changed or does not exist (anymore). +Thus, operating with history records is the most modular and robust way. + + +Types of history records +======================== + +In addition, to handle the history contained within a dataset, there is a +series of classes for storing history records: + + * :class:`HistoryRecord` + + Generic base class for all kinds of history records. + + For all classes operating on datasets, such as + :class:`aspecd.processing.SingleProcessingStep`, + :class:`aspecd.analysis.SingleAnalysisStep` and others, there exist at + least two "representations": (i) the generic one not (necessarily) tied + to any concrete dataset, thus portable, and (ii) a concrete one having + operated on a dataset and thus being accompanied with information about + who has done what when how to what dataset. + + For this second type, a history class derived from + :class:`aspecd.dataset.HistoryRecord` gets used, and it is this second type + that is stored inside the Dataset object. + + * :class:`ProcessingHistoryRecord` + + History record for processing steps on datasets. + + * :class:`AnalysisHistoryRecord` + + History record for analysis steps on datasets. + + * :class:`AnnotationHistoryRecord` + + History record for annotations of datasets. + + * :class:`PlotHistoryRecord` + + History record for plots of datasets. + + * :class:`TableHistoryRecord` + + History record for tables created from datasets. + + * :class:`DatasetExporterHistoryRecord` + + History record for exporters operating on datasets. + + +.. todo:: + + Clarifly the difference between the HistoryRecord and Record classes, + and explain which is used when and how. + + +Module documentation +==================== + """ from datetime import datetime diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 28866cb..bcc1f21 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -3584,7 +3584,7 @@ class AxesProperties(aspecd.utils.Properties): .. versionchanged:: 0.6 New properties ``xticklabelangle`` and ``yticklabelangle`` - .. versionchanged:: 0.8.4 + .. versionchanged:: 0.9 New property ``invert`` """ @@ -3748,7 +3748,7 @@ class LegendProperties(aspecd.utils.Properties): .. versionchanged:: 0.8 Added attribute :attr:`ncol` - .. versionchanged:: 0.8.4 + .. versionchanged:: 0.9 Added attribute :attr:`title` """ diff --git a/aspecd/processing.py b/aspecd/processing.py index 765337c..ef4828d 100644 --- a/aspecd/processing.py +++ b/aspecd/processing.py @@ -3073,7 +3073,7 @@ class CommonRangeExtraction(MultiProcessingStep): Unit of last axis (*i.e.*, intensity) gets ignored when checking for same units - .. versionchanged:: 0.8.4 + .. versionchanged:: 0.9 Works for *N*\ D datasets with arbitrary dimension *N* """ diff --git a/docs/changelog.rst b/docs/changelog.rst index fc546bb..6abcf95 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -14,15 +14,29 @@ Version 0.9.0 New features ------------ -* New property :attr:`aspecd.dataset.Dataset.device_data` for storing additional/secondary (monitoring) data. +* Processing steps + + * :class:`aspecd.processing.CommonRangeExtraction` works for *N*\ D datasets with arbitrary dimension *N* + +* Plotting + + * Legend title can be set from recipes + + * New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. Helpful, *e.g.*, for plotting FTIR data without having to resort to explicitly provide descending axis limits. + +* Device data + + * New property :attr:`aspecd.dataset.Dataset.device_data` for storing additional/secondary (monitoring) data. * New class :class:`aspecd.dataset.DeviceData` for device data. * New class :class:`aspecd.analysis.DeviceDataExtraction` for extracting device data from a dataset as a separate dataset. This allows to proceed with the extracted datasets as with any other dataset. * New class :class:`aspecd.plotting.MultiDeviceDataPlotter1D` for plotting multiple device data of a single dataset. -* New parameter ``device_data`` in :class:`aspecd.plotting.Plotter` for plotting device data rather than primary data of a dataset/datasets + * New parameter ``device_data`` in :class:`aspecd.plotting.Plotter` for plotting device data rather than primary data of a dataset/datasets -* New function :func:`aspecd.utils.get_logger` to get a logger object for a given module with the logger within the hierarchy of the ASpecD root logger. Important for packages derived from the ASpecD framework in order to get their log messages being captured, *e.g.* during recipe-driven data analysis. +* Logging + + * New function :func:`aspecd.utils.get_logger` to get a logger object for a given module with the logger within the hierarchy of the ASpecD root logger. Important for packages derived from the ASpecD framework in order to get their log messages being captured, *e.g.* during recipe-driven data analysis. Changes @@ -37,20 +51,6 @@ Changes * :class:`aspecd.io.DatasetExporter` adds a history record to :attr:`aspecd.dataset.Dataset.tasks`. -Version 0.8.4 -============= - -*Not yet released* - - -New features ------------- - -* :class:`aspecd.processing.CommonRangeExtraction` works for *N*\ D datasets with arbitrary dimension *N* -* Legend title can be set from recipes -* New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. Helpful, *e.g.*, for plotting FTIR data without having to resort to explicitly provide descending axis limits. - - Documentation ------------- From f530b98c6399083b5d4f34f5f636ba0c0cd34059 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 18 Sep 2023 21:54:04 +0200 Subject: [PATCH 28/55] Update roadmap --- VERSION | 2 +- docs/roadmap.rst | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 090ad2e..7e5914c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev53 +0.9.0.dev54 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index b389aef..6b8501a 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -10,6 +10,14 @@ For next releases * Plotting + * :class:`aspecd.plotting.MultiDeviceDataPlotter1DStacked` + + Similar to :class:`aspecd.plotting.MultiDeviceDataPlotter1D`, but stacked display of the individual lines as in :class:`aspecd.plotting.SinglePlotter2DStacked` + + * :class:`aspecd.plotting.`MultiDeviceDataPlotter1DSeparated` + + Similar to :class:`aspecd.plotting.MultiDeviceDataPlotter1D`, but with the different device data plotted in separate axes stacked vertically + * Colorbar for 2D plotter https://matplotlib.org/stable/api/figure_api.html#matplotlib.figure.Figure.colorbar @@ -44,6 +52,8 @@ For next releases Allows for creating a library of recipes for rather complex tasks that can simply be called as single step from another recipe + * Static (syntax) checker for recipes prior to their execution + * Report task: * Operating on recipes, *i.e.* report on all tasks in a recipe From a3aabc62c4777f9d7cc7bfa7eeaaf7b0037297c0 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 12:17:04 +0200 Subject: [PATCH 29/55] Colorbar for 2D plotter --- VERSION | 2 +- aspecd/plotting.py | 193 ++++++++++++++++++++++++++++++++++++++--- docs/changelog.rst | 2 + docs/roadmap.rst | 4 - tests/test_plotting.py | 78 +++++++++++++++++ 5 files changed, 263 insertions(+), 16 deletions(-) diff --git a/VERSION b/VERSION index 7e5914c..0598f71 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev54 +0.9.0.dev55 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index bcc1f21..3d945cd 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -181,6 +181,8 @@ * :class:`aspecd.plotting.GridProperties` + * :class:`aspecd.plotting.ColorbarProperties` + Getting and setting plot properties is somewhat complicated by the fact that Matplotlib usually allows setting properties only when instantiating objects, or sometimes with explicit setter methods. Similarly, there may @@ -1091,6 +1093,12 @@ class SinglePlotter2D(SinglePlotter): show_contour_lines : :class:`bool` Whether to show contour lines in case of contourf plot + show_colorbar : :class:`bool` + Whether to show a colorbar + + .. versionadded:: 0.9 + + properties : :class:`aspecd.plotting.SinglePlot2DProperties` Properties of the plot, defining its appearance @@ -1197,7 +1205,9 @@ def __init__(self): # noinspection PyTypeChecker self.parameters['levels'] = None self.parameters['show_contour_lines'] = False + self.parameters['show_colorbar'] = False self.properties = SinglePlot2DProperties() + self.colorbar = None self._type = 'imshow' self._allowed_types = ['contour', 'contourf', 'imshow'] @@ -1265,13 +1275,27 @@ def _create_plot(self): plotting class. """ - plot_function = getattr(self.axes, self.type) - data = self._shape_data() # matplotlib imshow and contour have incompatible properties if self.type == 'imshow': - self.drawing = plot_function(data, extent=self._get_extent(), - aspect='auto') - return + self._plot_imshow() + else: + self._plot_contour() + if self.parameters['show_colorbar']: + self.colorbar = self.fig.colorbar( + self.drawing, + ax=self.ax, + **self.properties.colorbar.kwargs + ) + + def _plot_imshow(self): + plot_function = getattr(self.axes, self.type) + data = self._shape_data() + self.drawing = plot_function(data, extent=self._get_extent(), + aspect='auto') + + def _plot_contour(self): + plot_function = getattr(self.axes, self.type) + data = self._shape_data() if self.parameters['levels']: self.drawing = plot_function(data, extent=self._get_extent(), levels=self.parameters['levels']) @@ -3082,6 +3106,15 @@ class SinglePlot2DProperties(SinglePlotProperties): For the properties that can be set this way, see the documentation of the :class:`aspecd.plotting.SurfaceProperties` class. + colorbar : :class:`aspecd.plotting.ColorbarProperties` + Properties of the colorbar (optionally) added to a plot + + For the properties that can be set this way, see the documentation + of the :class:`aspecd.plotting.ColorbarProperties` class. + + .. versionadded:: 0.9 + + Raises ------ aspecd.exceptions.MissingPlotterError @@ -3092,6 +3125,7 @@ class SinglePlot2DProperties(SinglePlotProperties): def __init__(self): super().__init__() self.drawing = SurfaceProperties() + self.colorbar = ColorbarProperties() self._colormap = '' self._include_in_to_dict = ['colormap'] @@ -3125,6 +3159,11 @@ def colormap(self, colormap): self._colormap = colormap self.drawing.cmap = self._colormap + def apply(self, plotter=None): + super().apply(plotter=plotter) + if plotter.colorbar: + self.colorbar.apply(colorbar=plotter.colorbar) + class MultiPlotProperties(PlotProperties): """ @@ -3329,8 +3368,7 @@ def apply(self, plotter=None): """ super().apply(plotter=plotter) if hasattr(plotter, 'drawings') and self.colormap: - idx = len(self.drawings) - colors = plt.get_cmap(self.colormap, idx) + colors = plt.get_cmap(self.colormap, len(self.drawings)) for idx, _ in enumerate(plotter.drawings): self.drawings[idx].color = colors(idx) @@ -3990,10 +4028,7 @@ def apply(self, drawing=None): class GridProperties(aspecd.utils.Properties): """ - Properties of a line within a plot. - - Basically, the attributes are a subset of what :mod:`matplotlib` defines - for :obj:`matplotlib.lines.Line2D` objects. + Properties of the grid of a plot. Attributes ---------- @@ -4065,3 +4100,139 @@ def apply(self, axes=None): **self.lines.settable_properties()) else: axes.grid(True, **self.lines.settable_properties()) + + +class ColorbarProperties(aspecd.utils.Properties): + """ + Properties of the colorbar of a plot. + + Basically, a subset of what :func:`matplotlib.figure.Figure.colorbar` + defines for a colorbar. + + Note that Matplotlib does not usually have an interface that easily + allows to both, set and query properties. For colorbars in particular, + many parameters can only be set when instantiating the colorbar object. + + Attributes + ---------- + location : :class:`str` + Location of the colorbar. + + Valid parameters: None or {'left', 'right', 'top', 'bottom'} + + fraction : :class:`float` + Fraction of original axes to use for colorbar. + + Default: 0.15 + + aspect : :class:`float` + Ratio of long to short dimensions. + + Default: 20. + + pad : :class:`float` + Fraction of original axes between colorbar and new image axes. + + Default: 0.05 if vertical, 0.15 if horizontal + + format : :class:`str` + Format of the tick labels + + label : :class:`dict` + The label on the colorbar's long axis and its properties. + + The following keys exist: + + text : :class:`str` + The label text + + location : :class:`str` + The location of the label + + Valid values depend on the orientation of the colorbar. + + * For horizontal orientation one of {'left', 'center', 'right'} + * For vertical orientation one of {'bottom', 'center', 'top'} + + + Examples + -------- + For convenience, a series of examples in recipe style (for details of + the recipe-driven data analysis, see :mod:`aspecd.tasks`) is given below + for how to make use of this class. + + Generally, the ColorbarProperties are set within the properties of the + respective plotter. + + .. code-block:: yaml + + - kind: singleplot + type: SinglePlotter2D + properties: + properties: + colorbar: + location: top + fraction: 0.1 + pad: 0.1 + format: "%4.2e" + label: + text: $foo$ / bar + location: left + + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.location = None + self.fraction = 0.15 + self.aspect = 20. + self.pad = None + self.format = '' + self.label = {'text': '', 'location': None} + self._exclude_from_kwargs = ['label'] + + @property + def kwargs(self): + """ + Properties that can/need to be set during colorbar object creation. + + Many parameters can only be set when instantiating the colorbar + object. For convenience, this property returns a dict with the + subset of properties that can (and need to) be set this way. + + Those properties of the class that cannot be set this way are listed + in the private attribute ``_exclude_from_kwargs``. Actually setting + the properties to a colorbar looks like: + + .. code-block:: + + fig.colorbar(drawing, ax=ax, **kwargs) + + Here, ``**kwargs`` is the expansion of the returned dictionary. + + Returns + ------- + kwargs : :class:`dict` + dict with kwargs that can be set when instantiating the colorbar. + + """ + kwargs = self.to_dict() + for key in self._exclude_from_kwargs: + kwargs.pop(key, None) + keys_to_drop = [key for key, value in kwargs.items() if not value] + for key in keys_to_drop: + kwargs.pop(key) + return kwargs + + def apply(self, colorbar=None): + self._set_colorbar_label(colorbar=colorbar) + + def _set_colorbar_label(self, colorbar=None): + if "location" in self.label and self.label['location']: + location = self.label['location'] + else: + location = None + colorbar.set_label(self.label['text'], loc=location) diff --git a/docs/changelog.rst b/docs/changelog.rst index 6abcf95..620a3c2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,6 +24,8 @@ New features * New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. Helpful, *e.g.*, for plotting FTIR data without having to resort to explicitly provide descending axis limits. + * Colorbar for 2D plotter + * Device data * New property :attr:`aspecd.dataset.Dataset.device_data` for storing additional/secondary (monitoring) data. diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 6b8501a..8485d80 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -18,10 +18,6 @@ For next releases Similar to :class:`aspecd.plotting.MultiDeviceDataPlotter1D`, but with the different device data plotted in separate axes stacked vertically - * Colorbar for 2D plotter - - https://matplotlib.org/stable/api/figure_api.html#matplotlib.figure.Figure.colorbar - * (Arbitrary) lines in plot, *e.g.* to compare peak positions Need to decide whether this goes into plotter properties or gets handled as proper annotations; probably the former, but a good starting point to think about the latter. diff --git a/tests/test_plotting.py b/tests/test_plotting.py index d3b9544..3c269d0 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -746,6 +746,41 @@ def test_plot_device_data_checks_applicability_of_device_data(self): with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): plotter = test_dataset.plot(self.plotter) + def test_colorbar_sets_colorbar(self): + self.plotter.parameters['show_colorbar'] = True + test_dataset = dataset.Dataset() + test_dataset.data.data = np.random.random([5, 5]) + plotter = test_dataset.plot(self.plotter) + self.assertIsInstance(plotter.colorbar, matplotlib.colorbar.Colorbar) + + def test_colorbar_properties_affect_colorbar(self): + self.plotter.parameters['show_colorbar'] = True + self.plotter.properties.colorbar.location = 'top' + test_dataset = dataset.Dataset() + test_dataset.data.data = np.random.random([5, 5]) + plotter = test_dataset.plot(self.plotter) + # Note: the location is not set in the colorbar, only the orientation + self.assertEqual('horizontal', plotter.colorbar.orientation) + + def test_colorbar_with_label_sets_label(self): + self.plotter.parameters['show_colorbar'] = True + self.plotter.properties.colorbar.label = {'text': 'foo'} + test_dataset = dataset.Dataset() + test_dataset.data.data = np.random.random([5, 5]) + plotter = test_dataset.plot(self.plotter) + self.assertEqual(self.plotter.properties.colorbar.label["text"], + plotter.colorbar.ax.yaxis.label.get_text()) + + def test_colorbar_with_label_location_sets_label_location(self): + self.plotter.parameters['show_colorbar'] = True + self.plotter.properties.colorbar.label = \ + {'text': 'foo', 'location': 'bottom'} + test_dataset = dataset.Dataset() + test_dataset.data.data = np.random.random([5, 5]) + plotter = test_dataset.plot(self.plotter) + self.assertEqual(0, + plotter.colorbar.ax.yaxis.label.get_position()[0]) + class TestSinglePlotter2DStacked(unittest.TestCase): def setUp(self): @@ -2631,6 +2666,46 @@ def test_apply_properties_sets_properties(self): plt.close(plot.figure) +class TestColorbarProperties(unittest.TestCase): + def setUp(self): + self.properties = plotting.ColorbarProperties() + fig, ax = plt.subplots() + self.colorbar = \ + fig.colorbar(matplotlib.cm.ScalarMappable(cmap='viridis'), ax=ax) + + def test_instantiate_class(self): + pass + + def test_has_to_dict_method(self): + self.assertTrue(hasattr(self.properties, 'to_dict')) + self.assertTrue(callable(self.properties.to_dict)) + + def test_has_from_dict_method(self): + self.assertTrue(hasattr(self.properties, 'from_dict')) + self.assertTrue(callable(self.properties.from_dict)) + + def test_has_properties(self): + for prop in ['location', 'fraction', 'aspect', 'pad', 'label', + 'format']: + self.assertTrue(hasattr(self.properties, prop)) + + def test_location_sets_legend_location(self): + location = 'top' + self.properties.location = location + plot = plotting.SinglePlotter2D() + plot.properties.colorbar = self.properties + plot.parameters['show_colorbar'] = True + dataset_ = dataset.Dataset() + dataset_.data.data = np.random.random([5, 5]) + plot.plot(dataset=dataset_) + # Note: the location is not set in the colorbar, only the orientation + self.assertEqual('horizontal', plot.colorbar.orientation) + plt.close(plot.figure) + + def test_kwargs_does_not_include_extra_parameters(self): + self.assertNotIn('label', self.properties.kwargs) + + class TestSinglePlotProperties(unittest.TestCase): def setUp(self): self.plot_properties = plotting.SinglePlotProperties() @@ -2733,6 +2808,9 @@ def test_setting_colormap_property_sets_drawing_cmap_property(self): def test_colormap_is_included_in_to_dict(self): self.assertIn('colormap', self.plot_properties.to_dict()) + def test_has_colorbar_property(self): + self.assertTrue(hasattr(self.plot_properties, 'colorbar')) + class TestMultiPlotProperties(unittest.TestCase): def setUp(self): From 242421b43c2cb1e619229c0adb9564e983945712 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 14:47:19 +0200 Subject: [PATCH 30/55] 1D Log plotters issue warning with negative values. --- VERSION | 2 +- aspecd/plotting.py | 145 +++++++++++++++++++++++++++++++++++++---- docs/changelog.rst | 2 + docs/roadmap.rst | 2 + tests/test_plotting.py | 128 ++++++++++++++++++++++++++++++++++++ 5 files changed, 264 insertions(+), 15 deletions(-) diff --git a/VERSION b/VERSION index 0598f71..2798856 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev55 +0.9.0.dev56 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 3d945cd..6fe2534 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -906,6 +906,30 @@ class SinglePlotter1D(SinglePlotter): properties: filename: output.pdf + Of course, line plots are not the only plot type available. Check the + :attr:`SinglePlotter1D.type` attribute for further details. To make a + semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the plotter as + follows: + + .. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + type: semilogy + filename: output.pdf + + .. important:: + + As the logarithm of negative values is not defined, usually having a + logarithmic axis with negative values will lead to unexpected + results. Matplotlib defaults to clipping the invalid values. To help + you with debugging the unexpected results, a warning will be logged + (and printed to the terminal when serving a recipe) in case a + logarithmic axis is affected by negative values. In such case, + the easiest is to add an offset to your data, using + :class:`aspecd.processing.ScalarAlgebra`. + Sometimes it is convenient to switch the *x* and *y* axes, *e.g.* in context of 2D datasets where slices along both dimensions should be displayed together with the 2D data and next to the respective axes. To @@ -938,6 +962,9 @@ class SinglePlotter1D(SinglePlotter): .. versionchanged:: 0.7 New parameter ``switch_axes`` + .. versionchanged:: 0.9 + Issue warning with log plotters and negative values + """ def __init__(self): @@ -992,6 +1019,7 @@ def type(self, plot_type=None): def _create_plot(self): plot_function = getattr(self.axes, self.type) + self._check_values_for_logplot() if not self.properties.drawing.label: self.properties.drawing.label = self.dataset.label if self.parameters['switch_axes']: @@ -1010,6 +1038,24 @@ def _create_plot(self): self.axes.set_ylim([self.data.data.min(), self.data.data.max()]) + def _check_values_for_logplot(self): + issue_warning = False + if self.parameters['switch_axes']: + xvalues = self.data.data + yvalues = self.data.axes[0].values + else: + xvalues = self.data.axes[0].values + yvalues = self.data.data + if 'semilogy' in self.type and np.min(yvalues) < 0: + issue_warning = True + if 'semilogx' in self.type and np.min(xvalues) < 0: + issue_warning = True + if 'loglog' in self.type \ + and (np.min(xvalues) < 0 or np.min(yvalues) < 0): + issue_warning = True + if issue_warning: + logger.warning('Negative values with %s plot detected.', self.type) + def _set_axes_labels(self): super()._set_axes_labels() if self.parameters['switch_axes']: @@ -2037,6 +2083,7 @@ def plot(self): self._set_legend() def _assign_data(self): + self.data = [] # Important, e.g., for CompositePlotter if self.parameters["device_data"]: device = self.parameters["device_data"] for dataset in self.datasets: @@ -2198,6 +2245,30 @@ class MultiPlotter1D(MultiPlotter): ``#``, you need to explicitly tell YAML that these are strings, surrounding the values by quotation marks. + Of course, line plots are not the only plot type available. Check the + :attr:`MultiPlotter1D.type` attribute for further details. To make a + semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the plotter as + follows: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1D + properties: + type: semilogy + filename: output.pdf + + .. important:: + + As the logarithm of negative values is not defined, usually having a + logarithmic axis with negative values will lead to unexpected + results. Matplotlib defaults to clipping the invalid values. To help + you with debugging the unexpected results, a warning will be logged + (and printed to the terminal when serving a recipe) in case a + logarithmic axis is affected by negative values. In such case, + the easiest is to add an offset to your data, using + :class:`aspecd.processing.ScalarAlgebra`. + Sometimes it is convenient to switch the *x* and *y* axes, *e.g.* in context of 2D datasets where slices along both dimensions should be displayed together with the 2D data and next to the respective axes. To @@ -2216,6 +2287,9 @@ class MultiPlotter1D(MultiPlotter): .. versionchanged:: 0.7 New parameters ``switch_axes`` and ``tight`` + .. versionchanged:: 0.9 + Issue warning with log plotters and negative values + """ def __init__(self): @@ -2287,30 +2361,31 @@ def applicable(data): def _create_plot(self): """Actual drawing of datasets""" plot_function = getattr(self.axes, self.type) + self._check_values_for_logplot() self.drawings = [] - for idx, dataset in enumerate(self.datasets): + for idx, data in enumerate(self.data): if not self.properties.drawings[idx].label: - self.properties.drawings[idx].label = dataset.label + self.properties.drawings[idx].label = self.datasets[idx].label if self.parameters['switch_axes']: drawing, = plot_function( - dataset.data.data, - dataset.data.axes[0].values, + data.data, + data.axes[0].values, label=self.properties.drawings[idx].label) else: drawing, = plot_function( - dataset.data.axes[0].values, - dataset.data.data, + data.axes[0].values, + data.data, label=self.properties.drawings[idx].label) self.drawings.append(drawing) if self.parameters['tight']: - axes_limits = [min(dataset.data.axes[0].values.min() - for dataset in self.datasets), - max(dataset.data.axes[0].values.max() - for dataset in self.datasets)] - data_limits = [min(dataset.data.data.min() - for dataset in self.datasets), - max(dataset.data.data.max() - for dataset in self.datasets)] + axes_limits = [min(data.axes[0].values.min() + for data in self.data), + max(data.axes[0].values.max() + for data in self.data)] + data_limits = [min(data.data.min() + for data in self.data), + max(data.data.max() + for data in self.data)] if self.parameters['tight'] in ('x', 'both'): if self.parameters['switch_axes']: self.axes.set_xlim(data_limits) @@ -2322,6 +2397,25 @@ def _create_plot(self): else: self.axes.set_ylim(data_limits) + def _check_values_for_logplot(self): + issue_warning = False + for data in self.data: + if self.parameters['switch_axes']: + xvalues = data.data + yvalues = data.axes[0].values + else: + xvalues = data.axes[0].values + yvalues = data.data + if 'semilogy' in self.type and np.min(yvalues) < 0: + issue_warning = True + if 'semilogx' in self.type and np.min(xvalues) < 0: + issue_warning = True + if 'loglog' in self.type \ + and (np.min(xvalues) < 0 or np.min(yvalues) < 0): + issue_warning = True + if issue_warning: + logger.warning('Negative values with %s plot detected.', self.type) + def _set_axes_labels(self): super()._set_axes_labels() if self.parameters['switch_axes']: @@ -3160,6 +3254,20 @@ def colormap(self, colormap): self.drawing.cmap = self._colormap def apply(self, plotter=None): + """ + Apply properties to plot. + + Parameters + ---------- + plotter: :class:`aspecd.plotting.SinglePlotter2D` + Plotter the properties should be applied to. + + Raises + ------ + aspecd.exceptions.MissingPlotterError + Raised if no plotter is provided. + + """ super().apply(plotter=plotter) if plotter.colorbar: self.colorbar.apply(colorbar=plotter.colorbar) @@ -4228,6 +4336,15 @@ def kwargs(self): return kwargs def apply(self, colorbar=None): + """ + Apply properties to colorbar. + + Parameters + ---------- + colorbar: :class:`matplotlib.colorbar.Colorbar` + Colorbar the properties should be applied to. + + """ self._set_colorbar_label(colorbar=colorbar) def _set_colorbar_label(self, colorbar=None): diff --git a/docs/changelog.rst b/docs/changelog.rst index 620a3c2..acfb297 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -52,6 +52,8 @@ Changes * :class:`aspecd.io.DatasetExporter` adds a history record to :attr:`aspecd.dataset.Dataset.tasks`. +* :class:`aspecd.plotting.SinglePlotter1D` and :class:`aspecd.plotting.MultiPlotter1D` issue warning with log plotters and negative values. + Documentation ------------- diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 8485d80..0c7a193 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -28,6 +28,8 @@ For next releases * Processing + * AxesAlgebra: add, subtract, multiply or divide axes values; useful, *e.g.*, when using log plotters, as the logarithm of a negative value is not defined and usually leads to surprises if not taken into account. + * CombineDatasets: combine data from several datasets into a single dataset; parameters allowing to define the axis values/quantity/unit, possibly even from given metadata; to decide: How to handle metadata that might be invalidated? * MetadataUpdate/MetadataChange: Change metadata of a given dataset from within a recipe. Useful in case datasets contain (known) spurious or otherwise inappropriate metadata. (Metadata are provided manually and are therefore prone to human errors). diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 3c269d0..2a43299 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -498,6 +498,62 @@ def test_plot_device_data_checks_applicability(self): with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): self.plotter.plot() + def test_semilogy_plot_with_negative_values_logs_warning(self): + self.plotter.type = 'semilogy' + self.dataset.data.data = \ + self.dataset.data.data - np.mean(self.dataset.data.data) + self.plotter.dataset = self.dataset + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('', cm.output[0]) + + def test_semilogx_plot_with_negative_axis_values_logs_warning(self): + self.plotter.type = 'semilogx' + self.dataset.data.axes[0].values = \ + np.linspace(-2, 2, len(self.dataset.data.data)) + self.plotter.dataset = self.dataset + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + + def test_loglog_plot_with_negative_values_logs_warning(self): + self.plotter.type = 'loglog' + self.dataset.data.data = \ + self.dataset.data.data - np.mean(self.dataset.data.data) + self.plotter.dataset = self.dataset + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + + def test_loglog_plot_with_negative_axis_values_logs_warning(self): + self.plotter.type = 'loglog' + self.dataset.data.axes[0].values = \ + np.linspace(-2, 2, len(self.dataset.data.data)) + self.plotter.dataset = self.dataset + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + + def test_semilogy_plot_w_neg_axis_values_and_switch_axis_logs_warning(self): + self.plotter.type = 'semilogy' + self.plotter.parameters['switch_axes'] = True + self.dataset.data.axes[0].values = \ + np.linspace(-2, 2, len(self.dataset.data.data)) + self.plotter.dataset = self.dataset + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('', cm.output[0]) + + def test_semilogx_plot_w_neg_values_and_switch_axis_logs_warning(self): + self.plotter.type = 'semilogx' + self.plotter.parameters['switch_axes'] = True + self.dataset.data.data = \ + self.dataset.data.data - np.mean(self.dataset.data.data) + self.plotter.dataset = self.dataset + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + class TestSinglePlotter2D(unittest.TestCase): def setUp(self): @@ -1613,6 +1669,74 @@ def test_plot_device_data_checks_applicability(self): with self.assertRaises(aspecd.exceptions.NotApplicableToDatasetError): self.plotter.plot() + def test_plot_device_data_plots_device_data(self): + device_data = dataset.DeviceData() + device_data.data = np.random.random(5) + self.dataset.device_data["test"] = device_data + self.plotter.parameters["device_data"] = "test" + self.plotter.datasets.append(self.dataset) + self.plotter.plot() + self.assertListEqual( + list(device_data.data), + list(self.plotter.axes.lines[0].get_ydata()) + ) + + def test_semilogy_plot_with_negative_values_logs_warning(self): + self.plotter.type = 'semilogy' + self.dataset.data.data = \ + self.dataset.data.data - np.mean(self.dataset.data.data) + self.plotter.datasets.append(self.dataset) + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('', cm.output[0]) + + def test_semilogx_plot_with_negative_axis_values_logs_warning(self): + self.plotter.type = 'semilogx' + self.dataset.data.axes[0].values = \ + np.linspace(-2, 2, len(self.dataset.data.data)) + self.plotter.datasets.append(self.dataset) + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + + def test_loglog_plot_with_negative_values_logs_warning(self): + self.plotter.type = 'loglog' + self.dataset.data.data = \ + self.dataset.data.data - np.mean(self.dataset.data.data) + self.plotter.datasets.append(self.dataset) + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + + def test_loglog_plot_with_negative_axis_values_logs_warning(self): + self.plotter.type = 'loglog' + self.dataset.data.axes[0].values = \ + np.linspace(-2, 2, len(self.dataset.data.data)) + self.plotter.datasets.append(self.dataset) + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + + def test_semilogy_plot_w_neg_axis_values_and_switch_axis_logs_warning(self): + self.plotter.type = 'semilogy' + self.plotter.parameters['switch_axes'] = True + self.dataset.data.axes[0].values = \ + np.linspace(-2, 2, len(self.dataset.data.data)) + self.plotter.datasets.append(self.dataset) + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('', cm.output[0]) + + def test_semilogx_plot_w_neg_values_and_switch_axis_logs_warning(self): + self.plotter.type = 'semilogx' + self.plotter.parameters['switch_axes'] = True + self.dataset.data.data = \ + self.dataset.data.data - np.mean(self.dataset.data.data) + self.plotter.datasets.append(self.dataset) + with self.assertLogs(__package__, level='WARNING') as cm: + self.plotter.plot() + self.assertIn('Negative values', cm.output[0]) + class TestMultiPlotter1DStacked(unittest.TestCase): def setUp(self): @@ -2786,6 +2910,10 @@ def setUp(self): def test_instantiate_class(self): pass + def test_apply_without_argument_raises(self): + with self.assertRaises(aspecd.exceptions.MissingPlotterError): + self.plot_properties.apply() + def test_apply_sets_drawing_properties(self): self.plot_properties.drawing.cmap = 'RdGy' plot = plotting.SinglePlotter2D() From cfa78a2a57c8771a3734a7404823d2d2064141d8 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 17:15:58 +0200 Subject: [PATCH 31/55] Documentation for plot properties --- VERSION | 2 +- aspecd/dataset.py | 2 + aspecd/plotting.py | 318 +++++++++++++++++++++++++++++++++++++++++++-- docs/roadmap.rst | 2 +- 4 files changed, 311 insertions(+), 13 deletions(-) diff --git a/VERSION b/VERSION index 2798856..83eedb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev56 +0.9.0.dev57 diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 00ba179..0cb1285 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -73,6 +73,8 @@ more) simulations. +.. _sec:dataset:device_data: + Device data =========== diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 6fe2534..e53ad80 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -1,6 +1,13 @@ """ Plotting: Graphical representations of data extracted from datasets. +.. sidebar:: Contents + + .. contents:: + :local: + :depth: 1 + + Plotting relies on `matplotlib `_, and mainly its object-oriented interface should be used for the actual plotting. Each plotter contains references to the respective figure and axes created usually @@ -12,6 +19,15 @@ properties of a plotter are available, named :attr:`fig` and :attr:`ax`, respectively. For details on handling (own) figure and axes objects, see below. + +Types of abstract plotters +========================== + +Abstract plotters are the base classes for all plotters actually used to +graphically display data. If you are mire interested in actually plotting +data rather than the overall concepts, have a look at :ref:`the concrete +plotters `. + Generally, two types of plotters can be distinguished: * Plotters for handling single datasets @@ -57,12 +73,13 @@ =================================== Something often quite confusing is the apparent inconsistency between the -order of array dimensions and the order of axes. While we are used to assign +order of array dimensions and the order of axes. While we are used to assigning axes in the order *x*, *y*, *z*, and assuming *x* to be horizontal, *y* vertical (and *z* sticking out of the paper plane), arrays are usually -indexed row-first, column-second. That means, however, that if you simply -plot a 2D array in axes, your *first* dimension is along the *y* axis, -the *second* dimension along the *x* axis. +(at least in the C world as compared to the FORTRAN world) indexed row-first, +column-second. That means, however, that if you simply plot a 2D array in +axes, your *first* dimension is along the *y* axis, the *second* dimension +along the *x* axis. Therefore, as the axes of your datasets will always correspond to the array dimensions of your data, in case of 2D plots you will need to *either* use @@ -80,6 +97,8 @@ the plotters to plot data and axes in a consistent fashion. +.. _sec:plotting:concrete_plotters: + Types of concrete plotters ========================== @@ -183,6 +202,10 @@ * :class:`aspecd.plotting.ColorbarProperties` +Each of the plot properties classes, *i.e.* all subclasses of +:class:`aspecd.plotting.PlotProperties`, contain other properties classes as +attributes. + Getting and setting plot properties is somewhat complicated by the fact that Matplotlib usually allows setting properties only when instantiating objects, or sometimes with explicit setter methods. Similarly, there may @@ -193,6 +216,273 @@ analysis <../recipes>` is highly recommended. +General tips and tricks +======================= + +Plotting can become horribly complicated, simply due to the complexity of +the matter involved and the parameters one can (and often want to) control. +For the convenience of the user, a few more general cases are discussed +below and example recipes provided for each case. For details on +recipe-driven data analysis, see either the :doc:`introduction ` +or the documentation of the :mod:`aspecd.tasks` module. + + +Overall figure properties +------------------------- + +On the figure level, *i.e.* the level of the overall graphical +representation, only a few properties can be set, namely size (in inches), +resolution (in dots per inch), and title: + + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + properties: + figure: + size: [8, 5] + resolution: 600 + title: My fancy figure + filename: output.pdf + + +.. important:: + + If you have a second axis on top of the axes, setting the figure title + will result in the figure title clashing with the upper axis. Hence, + in such case, try setting the axis title. + + + +Overall axes properties +----------------------- + +Axes properties can be set for :class:`SinglePlotter` and +:class:`MultiPlotter`, but for obvious reasons not for +:class:`CompositePlotter`. In case of the latter, the properties of the axes +are set for the individual plotters that are used to plot in the different +axes. + +Below is a demonstration of just a subset of the properties that can be set. +For further details, see the :class:`AxesProperties` class. Note in +particular that all the settings shown here for the *x* axis can be applied to +the *y* axis analogously. + + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + properties: + axes: + title: My fancy plot + aspect: equal + facecolor: darkgreen + xlabel: $foo$ / bar + xlim: [-5, 5] + xticklabelangle: 45 + invert: True + filename: output.pdf + + +.. important:: + + If you have a second axis on top of the axes, setting the figure title + will result in the figure title clashing with the upper axis. Hence, + in such case, try setting the axis title. + + + +Type of plot for 1D plotters +---------------------------- + +When plotting one-dimensional (1D) data, there is of course more than the +usual line plot. For the actual types of plots that you can use, see the +:attr:`SinglePlotter1D.allowed_types` and :attr:`MultiPlotter1D.allowed_types` +attributes. + +To make a semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the +plotter as follows: + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + type: semilogy + filename: output.pdf + +And analogous for the MultiPlot1D plotter: + +.. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1D + properties: + type: semilogy + filename: output.pdf + + +.. important:: + + As the logarithm of negative values is not defined, usually having a + logarithmic axis with negative values will lead to unexpected + results. Matplotlib defaults to clipping the invalid values. To help + you with debugging the unexpected results, a warning will be logged + (and printed to the terminal when serving a recipe) in case a + logarithmic axis is affected by negative values. In such case, + the easiest is to add an offset to your data, using + :class:`aspecd.processing.ScalarAlgebra`. + + +Appearance of individual drawings +--------------------------------- + +The individual drawings within the axes of a plot can be controlled in quite +some detail. Depending on the overall type, be it a line or a surface, +there are different classes responsible for setting the properties: +:class:`LineProperties` and :class:`SurfaceProperties`. The general class is +:class:`DrawingProperties`. + + +Adding a grid +------------- + +Particularly when comparing plots or when you want to extract values from a +plot, a grid can come in quite handy. As a grid is already quite complicated +-- for which axis (*x*, *y*, or both) to set the grid, for which ticks (minor, +major, or both) -- and as you may even want to control the appearance of the +grid lines, all these properties are handled by the :class:`GridProperties` +class. You can add a grid to both, :class:`SinglePlotter` and +:class:`MultiPlotter` instances. + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + properties: + grid: + show: True + ticks: major + axis: both + +If you now even want to control the appearance of the grid lines (you can +not, however, control individual grid lines, only all grid lines at once), +things get even more complex: + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + properties: + grid: + show: True + ticks: major + axis: both + lines: + color: #123456 + linestyle: dashed + linewidth: 3 + marker: triangle_up + +Note that the values for the lines are not necessarily sensible for grid +lines. For a full list of possible properties, see the +:class:`LineProperties` class. The same as shown here for a +:class:`SinglePlotter` can be done for a :class:`MultiPlotter` accordingly. + +Adding a legend +--------------- + +As soon as there is more than one line in a plot, adding a legend comes in +quite handy. Again, a legend can be controlled in quite some detail. An +example showing some of the properties that can be set is given below: + + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + parameters: + show_legend: True + properties: + legend: + location: upper right + frameon: False + labelspacing: 0.75 + fontsize: small + ncol: 2 + title: some explanation + + +Important here is to note that you need to set the ``show_legend`` parameter +on a higher level of the overall plotter properties to ``True`` in order to +have a legend be shown. Of course, you need not set all (or even any) of the +properties explicitly. For details, see the :class:`LegendProperties` class. + + +Adding a colorbar +----------------- + +For two-dimensional (2D) plots, adding a colorbar that provides some +information on the intensity values encoded in different colors is usually a +good idea. The properties of the colorbar can be set via the +:class:`ColorbarProperties` class. + + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter2D + properties: + parameters: + show_colorbar: True + properties: + colorbar: + location: top + fraction: 0.1 + aspect: 30 + pad: 0.2 + format: "%4.2e" + label: + text: $intensity$ / a.u. + location: right + + +Again, you need not to set any of the properties explicitly, besides setting +the parameter ``show_colorbar`` to ``True``. If none of the properties are +set explicitly, the defaults provided by Matplotlib will be used. + + +Plotting device data rather than primary data +--------------------------------------------- + +Datasets may contain additional data as device data in +:attr:`aspecd.dataset.Dataset.device_data`. For details, +see the :ref:`section on device data in the dataset module +`. To conveniently plot those device data instead +of the primary data of the dataset, provide the key(s) to the device(s) the +data should be plotted for: + + +.. code-block:: yaml + + - kind: singleplot + type: SinglePlotter1D + properties: + parameters: + device_data: timestamp + filename: output.pdf + + +Basically, all plotters understand device data and will plot the device data +rather than the primary data of the dataset accordingly. + Plotting to existing axes ========================= @@ -907,9 +1197,9 @@ class SinglePlotter1D(SinglePlotter): filename: output.pdf Of course, line plots are not the only plot type available. Check the - :attr:`SinglePlotter1D.type` attribute for further details. To make a - semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the plotter as - follows: + :attr:`SinglePlotter1D.allowed_types` attribute for further details. To + make a semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the + plotter as follows: .. code-block:: yaml @@ -1003,6 +1293,9 @@ def allowed_types(self): """ Return the allowed plot types. + Currently, the allowed types are: ``plot``, ``scatter``, ``step``, + ``loglog``, ``semilogx``, ``semilogy``, ``stemplot``. + Returns ------- allowed_types: :class:`list` @@ -2246,9 +2539,9 @@ class MultiPlotter1D(MultiPlotter): surrounding the values by quotation marks. Of course, line plots are not the only plot type available. Check the - :attr:`MultiPlotter1D.type` attribute for further details. To make a - semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the plotter as - follows: + :attr:`MultiPlotter1D.allowed_types` attribute for further details. To + make a semilogy plot (*i.e.*, with logarithmic *y* axis), invoke the + plotter as follows: .. code-block:: yaml @@ -2328,6 +2621,9 @@ def allowed_types(self): """ Return the allowed plot types. + Currently, the allowed types are: ``plot``, ``step``, ``loglog``, + ``semilogx``, ``semilogy``. + Returns ------- allowed_types: :class:`list` @@ -4214,7 +4510,7 @@ class ColorbarProperties(aspecd.utils.Properties): """ Properties of the colorbar of a plot. - Basically, a subset of what :func:`matplotlib.figure.Figure.colorbar` + Basically, a subset of what :meth:`matplotlib.figure.Figure.colorbar` defines for a colorbar. Note that Matplotlib does not usually have an interface that easily diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 0c7a193..90679e9 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -14,7 +14,7 @@ For next releases Similar to :class:`aspecd.plotting.MultiDeviceDataPlotter1D`, but stacked display of the individual lines as in :class:`aspecd.plotting.SinglePlotter2DStacked` - * :class:`aspecd.plotting.`MultiDeviceDataPlotter1DSeparated` + * :class:`aspecd.plotting.MultiDeviceDataPlotter1DSeparated` Similar to :class:`aspecd.plotting.MultiDeviceDataPlotter1D`, but with the different device data plotted in separate axes stacked vertically From 0eef8f0fa217ddeaf8dcf180028bc323fd53772c Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 18:32:50 +0200 Subject: [PATCH 32/55] Further documentation of how to style plots. --- VERSION | 2 +- aspecd/plotting.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 83eedb9..6810d13 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev57 +0.9.0.dev58 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index e53ad80..edd204c 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -347,6 +347,63 @@ :class:`LineProperties` and :class:`SurfaceProperties`. The general class is :class:`DrawingProperties`. +Below is a (real-world) example of a multiplotter containing two lines, +and in this particular case with standard settings. + +.. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1D + properties: + properties: + drawings: + - label: Substance 1 + color: '#1f77b4' + drawstyle: default + linestyle: '-' + linewidth: 1.5 + marker: None + - label: Substance 2 + color: '#ff7f0e' + drawstyle: default + linestyle: '-' + linewidth: 1.5 + marker: None + + +Controlling the appearance of zero lines +---------------------------------------- + +While a grid is not shown by default, zero lines are, as long as the zero +value is present in either or both axes ranges. While it is a sensible +default to display zero lines, and switching them off is a matter of +setting the parameter ``show_zero_lines`` to ``False``, controlling the +appearance of these lines is often useful. Below is a (real-world) example +of the available settings for the zero lines (with default values). + +.. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1D + properties: + parameters: + show_zero_lines: true + properties: + zero_lines: + label: '' + color: '#cccccc' + drawstyle: default + linestyle: solid + linewidth: 1.0 + marker: '' + + +While it rarely makes sense to set line markers for these lines, the line +properties are simply all properties that can be set using the +:class:`LineProperties` class. Besides controlling the appearance of zero +lines, you can display a grid and control the appearance of these lines. +See below for more details. + Adding a grid ------------- @@ -395,6 +452,7 @@ :class:`LineProperties` class. The same as shown here for a :class:`SinglePlotter` can be done for a :class:`MultiPlotter` accordingly. + Adding a legend --------------- From 86fe5371061be7daf2e82c15571641176f35f897 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 18:41:07 +0200 Subject: [PATCH 33/55] Update roadmap and changelog --- VERSION | 2 +- aspecd/plotting.py | 2 ++ docs/changelog.rst | 1 + docs/roadmap.rst | 2 -- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 6810d13..3ff711d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev58 +0.9.0.dev59 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index edd204c..a9c2688 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -216,6 +216,8 @@ analysis <../recipes>` is highly recommended. +.. _sec:plotting:tips_tricks: + General tips and tricks ======================= diff --git a/docs/changelog.rst b/docs/changelog.rst index acfb297..569269f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -59,6 +59,7 @@ Documentation ------------- * New example: :doc:`Plotting FTIR spectra normalised to spectral feature ` +* Section with :ref:`general tips and tricks for styling plotters `. Version 0.8.3 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 90679e9..7400073 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -28,8 +28,6 @@ For next releases * Processing - * AxesAlgebra: add, subtract, multiply or divide axes values; useful, *e.g.*, when using log plotters, as the logarithm of a negative value is not defined and usually leads to surprises if not taken into account. - * CombineDatasets: combine data from several datasets into a single dataset; parameters allowing to define the axis values/quantity/unit, possibly even from given metadata; to decide: How to handle metadata that might be invalidated? * MetadataUpdate/MetadataChange: Change metadata of a given dataset from within a recipe. Useful in case datasets contain (known) spurious or otherwise inappropriate metadata. (Metadata are provided manually and are therefore prone to human errors). From 07da1ad9c811ec3175785d34d08aec920159f1ef Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 20:53:41 +0200 Subject: [PATCH 34/55] Rename Annotation to DatasetAnnotation; update documentation --- VERSION | 2 +- aspecd/annotation.py | 60 ++++++++++++++++++++++++++-------------- aspecd/plotting.py | 2 ++ docs/changelog.rst | 2 ++ docs/roadmap.rst | 2 +- tests/test_annotation.py | 2 +- tests/test_dataset.py | 6 ++-- tests/test_history.py | 8 +++--- 8 files changed, 54 insertions(+), 30 deletions(-) diff --git a/VERSION b/VERSION index 3ff711d..105d444 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev59 +0.9.0.dev60 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index 07b0856..868fe20 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -1,9 +1,17 @@ """ -Annotations of data, e.g. characteristics, that cannot be automated. +Annotations of data, *e.g.* characteristics, that cannot be automated. -Annotations of data are eventually something that cannot be automated. -Nevertheless, they can be quite important for the analysis and hence for -providing new scientific insight. +Annotations of data (and plots, *i.e.* graphical representations of data) are +eventually something that cannot be automated. Nevertheless, they can be +quite important for the analysis and hence for providing new scientific +insight. Furthermore, annotations of data can sometimes be added to a +graphical representation. A typical example would be to mark an artefact +with an asterisk or to highlight a characteristic. Therefore, dataset +annotations may have graphical realisations as plot annotations. + + +Dataset annotations +=================== The simplest form of an annotation is a comment applying to an entire dataset, such as comments stored in the metadata written during data @@ -12,10 +20,17 @@ :obj:`aspecd.annotation.Comment` object. Other frequent types of annotations are artefacts and characteristics, -for which dedicated classes are available within the ASpecD framework: -:class:`aspecd.annotation.Artefact` and -:class:`aspecd.annotation.Characteristic`. For other types of annotations, -simply subclass the :class:`aspecd.annotation.Annotation` base class. +for which dedicated classes are available within the ASpecD framework, are: + +* :class:`aspecd.annotation.Artefact` +* :class:`aspecd.annotation.Characteristic`. + +For other types of annotations, simply subclass the +:class:`aspecd.annotation.Annotation` base class. + + +Module documentation +==================== """ @@ -24,7 +39,7 @@ from aspecd.utils import ToDictMixin -class Annotation(ToDictMixin): +class DatasetAnnotation(ToDictMixin): """ Annotations are user-supplied additional information to datasets. @@ -93,6 +108,9 @@ def scope(self): stored in the private property `_default_scope` (and is defined as one element of the list of allowed scopes) + Currently, allowed scopes are: ``dataset``, ``slice``, ``point``, + ``area``, ``distance``. + """ return self._scope @@ -108,20 +126,22 @@ def annotate(self, dataset=None, from_dataset=False): Annotate a dataset with the given annotation. If no dataset is provided at method call, but is set as property in - the Annotation object, the process method of the dataset will be - called and thus the history written. + the Annotation object, the :meth:`aspecd.dataset.Dataset.annotate` + method of the dataset will be called and thus the history written. If no dataset is provided at method call nor as property in the object, the method will raise a respective exception. If no scope is set in the :obj:`aspecd.annotation.Annotation` object, a default value will be used that can be set in derived - classes in the private property `_default_scope`. A full list of - scopes is contained in the private property `_allowed_scopes` + classes in the private property ``_default_scope``. A full list of + scopes is contained in the private property ``_allowed_scopes``. + See the :attr:`scope` property for details. - The Dataset object always calls this method with the respective - dataset as argument. Therefore, in this case setting the dataset - property within the Annotation object is not necessary. + The :obj:`aspecd.dataset.Dataset` object always calls this method + with the respective dataset as argument. Therefore, in this case + setting the dataset property within the Annotation object is not + necessary. Parameters ---------- @@ -150,7 +170,7 @@ def create_history_record(self): Create history record to be added to the dataset. Usually, this method gets called from within the - :meth:`aspecd.dataset.annotate` method of the + :meth:`aspecd.dataset.Dataset.annotate` method of the :class:`aspecd.dataset.Dataset` class and ensures the history of each annotation step to get written properly. @@ -184,7 +204,7 @@ def _call_from_dataset(self, from_dataset): self.dataset.annotate(self) -class Comment(Annotation): +class Comment(DatasetAnnotation): """The most basic form of annotation: a simple textual comment.""" def __init__(self): @@ -209,7 +229,7 @@ def comment(self, comment=''): self.content['comment'] = comment -class Artefact(Annotation): +class Artefact(DatasetAnnotation): """Mark something as an artefact.""" def __init__(self): @@ -217,5 +237,5 @@ def __init__(self): self.content['comment'] = '' -class Characteristic(Annotation): +class Characteristic(DatasetAnnotation): """Base class for characteristics.""" diff --git a/aspecd/plotting.py b/aspecd/plotting.py index a9c2688..20743c6 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -1635,6 +1635,8 @@ def allowed_types(self): """ Return the allowed plot types. + Currently allowed types are: ``contour``, ``contourf``, ``imshow`` + Returns ------- allowed_types: :class:`list` diff --git a/docs/changelog.rst b/docs/changelog.rst index 569269f..a5430ac 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -54,6 +54,8 @@ Changes * :class:`aspecd.plotting.SinglePlotter1D` and :class:`aspecd.plotting.MultiPlotter1D` issue warning with log plotters and negative values. +* :class:`aspecd.annotation.DatasetAnnotation` has been renamed from ``Annotation`` to reflect the fact that there are now plot annotations as well. + Documentation ------------- diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 7400073..0563d54 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -28,7 +28,7 @@ For next releases * Processing - * CombineDatasets: combine data from several datasets into a single dataset; parameters allowing to define the axis values/quantity/unit, possibly even from given metadata; to decide: How to handle metadata that might be invalidated? + * DatasetCombination: combine data from several datasets into a single dataset; parameters allowing to define the axis values/quantity/unit, possibly even from given metadata; to decide: How to handle metadata that might be invalidated? * MetadataUpdate/MetadataChange: Change metadata of a given dataset from within a recipe. Useful in case datasets contain (known) spurious or otherwise inappropriate metadata. (Metadata are provided manually and are therefore prone to human errors). diff --git a/tests/test_annotation.py b/tests/test_annotation.py index a09fac9..0a06add 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -10,7 +10,7 @@ class TestAnnotation(unittest.TestCase): def setUp(self): - self.annotation = aspecd.annotation.Annotation() + self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation.content['foo'] = 'bar' def test_instantiate_class(self): diff --git a/tests/test_dataset.py b/tests/test_dataset.py index de35a0b..522b525 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -402,7 +402,7 @@ def test_added_task_has_analysis_history_record(self): class TestDatasetAnnotation(unittest.TestCase): def setUp(self): self.dataset = dataset.Dataset() - self.annotation = annotation.Annotation() + self.annotation = annotation.DatasetAnnotation() self.annotation.content = 'boo' def test_has_annotate_method(self): @@ -629,7 +629,7 @@ def test_to_dict_with_analysis_step(self): self.dataset.to_dict() def test_to_dict_with_annotation(self): - annotation_step = aspecd.annotation.Annotation() + annotation_step = aspecd.annotation.DatasetAnnotation() annotation_step.content = 'foo' self.dataset.annotate(annotation_step) self.dataset.to_dict() @@ -723,7 +723,7 @@ def test_from_dict_sets_analyses(self): new_dataset.analyses[0].to_dict()) def test_from_dict_sets_annotations(self): - annotation_ = aspecd.annotation.Annotation() + annotation_ = aspecd.annotation.DatasetAnnotation() annotation_.content = {'foo': 'bar'} self.dataset.annotate(annotation_) dataset_dict = self.dataset.to_dict() diff --git a/tests/test_history.py b/tests/test_history.py index 293d1a6..d297e19 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -387,12 +387,12 @@ def test_replay(self): class TestAnnotationRecord(unittest.TestCase): def setUp(self): - self.annotation = aspecd.annotation.Annotation() + self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation_record = \ aspecd.history.AnnotationRecord(self.annotation) def test_instantiate_class(self): - aspecd.annotation.Annotation() + aspecd.annotation.DatasetAnnotation() def test_instantiate_class_with_annotation(self): aspecd.history.AnnotationRecord(self.annotation) @@ -421,7 +421,7 @@ def test_has_create_annotation_method(self): def test_create_annotation_returns_annotation_object(self): test_object = self.annotation_record.create_annotation() - self.assertTrue(isinstance(test_object, aspecd.annotation.Annotation)) + self.assertTrue(isinstance(test_object, aspecd.annotation.DatasetAnnotation)) def test_annotation_object_has_correct_contents_value(self): self.annotation_record.content = {'foo': 'bar'} @@ -443,7 +443,7 @@ def test_from_dict(self): class TestAnnotationHistoryRecord(unittest.TestCase): def setUp(self): - self.annotation = aspecd.annotation.Annotation() + self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation_record = aspecd.history.AnnotationHistoryRecord( annotation=self.annotation) From 393eb6023083ba8e5f1cf96fbac1614f268b7488 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Tue, 19 Sep 2023 21:56:45 +0200 Subject: [PATCH 35/55] Start with PlotAnnotation class; still failing tests --- VERSION | 2 +- aspecd/annotation.py | 95 +++++++++++++++++++++++++++++++++++++--- aspecd/dataset.py | 5 ++- aspecd/history.py | 43 +++--------------- tests/test_annotation.py | 87 +++++++++++++++++++++++++++++++++++- tests/test_dataset.py | 4 +- tests/test_history.py | 28 ++++++------ 7 files changed, 204 insertions(+), 60 deletions(-) diff --git a/VERSION b/VERSION index 105d444..d5f3302 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev60 +0.9.0.dev61 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index 868fe20..eb2c7b1 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -29,6 +29,12 @@ :class:`aspecd.annotation.Annotation` base class. +Plot(ter) annotations +===================== + +TBD + + Module documentation ==================== @@ -52,9 +58,9 @@ class DatasetAnnotation(ToDictMixin): e.g. saying that a dataset is not useful as something during measurement went wrong, they can highlight "characteristics" of the data, they can point to "artefacts". Each of these types is represented by a class on - its own that is derived from the "Annotation" base class. Additionally, - the type is reflected in the "type" property that gets set automatically to - the class name in lower-case letters. + its own that is derived from the :class:`DatasetAnnotation` base class. + Additionally, the type is reflected in the "type" property that gets + set automatically to the class name in lower-case letters. Each annotation has a scope (such as "point", "slice", "area", "distance", "dataset") it belongs to, and a "contents" property (dict) containing the @@ -176,11 +182,11 @@ def create_history_record(self): Returns ------- - history_record : :class:`aspecd.history.AnnotationHistoryRecord` + history_record : :class:`aspecd.history.DatasetAnnotationHistoryRecord` history record for annotation step """ - history_record = aspecd.history.AnnotationHistoryRecord( + history_record = aspecd.history.DatasetAnnotationHistoryRecord( annotation=self, package=self.dataset.package_name) return history_record @@ -239,3 +245,82 @@ def __init__(self): class Characteristic(DatasetAnnotation): """Base class for characteristics.""" + + +class PlotAnnotation(ToDictMixin): + """ + Base class for annotations for graphical representations (plots). + + Whereas many processing steps of data can be fully automated, annotations + are mostly the domain of human interaction, looking at the graphical + representation of the data of a dataset and providing some sort of + comments, trying to make sense of the data. + + Annotations can have different types, such as ... + + Each of these types is represented by a class on + its own that is derived from the :class:`PlotAnnotation` base class. + Additionally, the type is reflected in the "type" property that gets + set automatically to the class name in lower-case letters. + + Attributes + ---------- + plotter : :class:`aspecd.plotting.Plotter` + + type : :class:`str` + + parameters : :class:`dict` + + properties : :class:`None` + """ + + def __init__(self): + super().__init__() + self.plotter = None + self.type = self.__class__.__name__.lower() + self.parameters = {} + self.properties = None + self._exclude_from_to_dict = ['plotter', 'type'] + + def annotate(self, plotter=None): + """ + Annotate a plot(ter) with the given annotation. + + If no plotter is provided at method call, but is set as property in + the Annotation object, the :meth:`aspecd.plotting.Plotter.annotate` + method of the plotter will be called and thus the history written. + + If no plotter is provided at method call nor as property in the + object, the method will raise a respective exception. + + Parameters + ---------- + plotter : :class:`aspecd.plotting.Plotter` + Plot(ter) to annotate + + Returns + ------- + plotter : :class:`aspecd.plotting.Plotter` + Plotter that has been annotated + + """ + return self.plotter + + def create_history_record(self): + """ + Create history record to be added to the plotter. + + Usually, this method gets called from within the + :meth:`aspecd.plotting.Plotter.annotate` method of the + :class:`aspecd.plotting.Plotter` class and ensures the history of + each annotation step to get written properly. + + Returns + ------- + history_record : :class:`aspecd.history.PlotAnnotationHistoryRecord` + history record for annotation step + + """ + history_record = aspecd.history.PlotAnnotationHistoryRecord( + annotation=self, package=aspecd.utils.package_name(self.plotter)) + return history_record diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 0cb1285..4554564 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -767,7 +767,7 @@ class are set accordingly. elif key == "annotations": for element in dict_[key]: record = \ - aspecd.history.AnnotationHistoryRecord() + aspecd.history.DatasetAnnotationHistoryRecord() record.from_dict(element) self.annotations.append(record) elif key == "representations": @@ -785,6 +785,9 @@ class are set accordingly. if element["kind"] == "representation": record_class_name = \ 'aspecd.history.PlotHistoryRecord' + elif element["kind"] == "annotation": + record_class_name = \ + 'aspecd.history.DatasetAnnotationHistoryRecord' else: record_class_name = 'aspecd.history.' \ + element["kind"].capitalize() + 'HistoryRecord' diff --git a/aspecd/history.py b/aspecd/history.py index 81cc4cb..2cfd2fc 100644 --- a/aspecd/history.py +++ b/aspecd/history.py @@ -499,13 +499,6 @@ class AnalysisHistoryRecord(HistoryRecord): analysis : :class:`aspecd.analysis.SingleAnalysisStep` Analysis step the history is saved for - package : :class:`str` - Name of package the history record gets recorded for - - Prerequisite for reproducibility, gets stored in the - :attr:`aspecd.dataset.HistoryRecord.sysinfo` attribute. - Will usually be provided automatically by the dataset. - Parameters ---------- analysis_step : :class:`aspecd.analysis.SingleAnalysisStep` @@ -533,7 +526,7 @@ def replay(self, dataset): dataset.analyse(analysis_step=analysis_step) -class AnnotationRecord(aspecd.utils.ToDictMixin): +class DatasetAnnotationRecord(aspecd.utils.ToDictMixin): """Base class for annotation records stored in the dataset annotations. The annotation of a :class:`aspecd.dataset.Dataset` should *not* contain @@ -634,21 +627,14 @@ class are set accordingly. setattr(self, key, value) -class AnnotationHistoryRecord(HistoryRecord): - """History record for annotations of datasets. +class DatasetAnnotationHistoryRecord(HistoryRecord): + """History record for annotations of datasets or plots. Attributes ---------- annotation : :class:`aspecd.annotation.Annotation` Annotation the history is saved for - package : :class:`str` - Name of package the history record gets recorded for - - Prerequisite for reproducibility, gets stored in the - :attr:`aspecd.dataset.HistoryRecord.sysinfo` attribute. - Will usually be provided automatically by the dataset. - Parameters ---------- annotation : :class:`aspecd.annotation.AnnotationRecord` @@ -661,7 +647,7 @@ class AnnotationHistoryRecord(HistoryRecord): def __init__(self, annotation=None, package=''): super().__init__(package=package) - self.annotation = AnnotationRecord(annotation) + self.annotation = DatasetAnnotationRecord(annotation) class PlotRecord(aspecd.utils.ToDictMixin): @@ -834,13 +820,12 @@ class PlotHistoryRecord(HistoryRecord): plot : :class:`aspecd.plotting.SinglePlotRecord` Plot the history is saved for + + Parameters + ---------- package : :class:`str` Name of package the history record gets recorded for - Prerequisite for reproducibility, gets stored in the - :attr:`aspecd.dataset.HistoryRecord.sysinfo` attribute. - Will usually be provided automatically by the dataset. - """ def __init__(self, package=''): @@ -960,13 +945,6 @@ class TableHistoryRecord(HistoryRecord): table : :class:`aspecd.table.Table` Table the history is saved for - package : :class:`str` - Name of package the history record gets recorded for - - Prerequisite for reproducibility, gets stored in the - :attr:`aspecd.dataset.HistoryRecord.sysinfo` attribute. - Will usually be provided automatically by the dataset. - Parameters ---------- table : :class:`aspecd.table.Table` @@ -1088,13 +1066,6 @@ class DatasetExporterHistoryRecord(HistoryRecord): exporter : :class:`aspecd.io.DatasetExporter` Dataset exporter the history is saved for - package : :class:`str` - Name of package the history record gets recorded for - - Prerequisite for reproducibility, gets stored in the - :attr:`aspecd.dataset.HistoryRecord.sysinfo` attribute. - Will usually be provided automatically by the dataset. - Parameters ---------- exporter : :class:`aspecd.table.DatasetExporter` diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 0a06add..a51103f 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -6,9 +6,10 @@ import aspecd.dataset import aspecd.exceptions import aspecd.history +import aspecd.plotting -class TestAnnotation(unittest.TestCase): +class TestDatasetAnnotation(unittest.TestCase): def setUp(self): self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation.content['foo'] = 'bar' @@ -100,7 +101,7 @@ def test_create_history_record_returns_history_record(self): self.annotation.dataset = aspecd.dataset.Dataset() history_record = self.annotation.create_history_record() self.assertTrue(isinstance(history_record, - aspecd.history.AnnotationHistoryRecord)) + aspecd.history.DatasetAnnotationHistoryRecord)) class TestComment(unittest.TestCase): @@ -148,3 +149,85 @@ def setUp(self): def test_instantiate_class(self): pass + + +# @unittest.skip +class TestPlotAnnotation(unittest.TestCase): + def setUp(self): + self.annotation = aspecd.annotation.PlotAnnotation() + + def test_instantiate_class(self): + pass + + def test_has_plotter_property(self): + self.assertTrue(hasattr(self.annotation, 'plotter')) + + def test_plotter_property_is_initially_none(self): + self.assertEqual(self.annotation.plotter, None) + + def test_has_type_property(self): + self.assertTrue(hasattr(self.annotation, 'type')) + + def test_type_property_equals_lower_classname(self): + self.assertEqual(self.annotation.type, + self.annotation.__class__.__name__.lower()) + + def test_has_parameters_property(self): + self.assertTrue(hasattr(self.annotation, 'parameters')) + self.assertIsInstance(self.annotation.parameters, dict) + + def test_has_properties_property(self): + self.assertTrue(hasattr(self.annotation, 'properties')) + + def test_has_to_dict_method(self): + self.assertTrue(hasattr(self.annotation, 'to_dict')) + self.assertTrue(callable(self.annotation.to_dict)) + + def test_to_dict_does_not_contain_certain_keys(self): + for key in ['plotter', 'type']: + with self.subTest(key=key): + self.assertNotIn(key, self.annotation.to_dict()) + + def test_has_annotate_method(self): + self.assertTrue(hasattr(self.annotation, 'annotate')) + self.assertTrue(callable(self.annotation.annotate)) + + def test_annotate_without_argument_and_with_plotter(self): + self.annotation.plotter = aspecd.plotting.Plotter() + self.annotation.annotate() + self.assertGreater(len(self.annotation.plotter.annotations), 0) + + def test_annotate_via_plotter_annotate(self): + test_plotter = aspecd.plotting.Plotter() + test_plotter.annotate(self.annotation) + self.assertGreater(len(test_plotter.annotations), 0) + + def test_annotate_with_plotter(self): + test_plotter = aspecd.plotting.Plotter() + self.annotation.annotate(test_plotter) + self.assertGreater(len(test_plotter.annotations), 0) + + def test_annotate_without_argument_nor_plotter_raises(self): + with self.assertRaises(aspecd.exceptions.MissingPlotterError): + self.annotation.annotate() + + def test_annotate_with_empty_content_raises(self): + self.annotation.plotter = aspecd.plotting.Plotter() + self.annotation.content.clear() + with self.assertRaises(aspecd.exceptions.NoContentError): + self.annotation.annotate() + + def test_annotate_returns_plotter(self): + test_plotter = self.annotation.annotate(aspecd.plotting.Plotter()) + self.assertTrue(isinstance(test_plotter, aspecd.plotting.Plotter)) + + def test_has_create_history_record_method(self): + self.assertTrue(hasattr(self.annotation, 'create_history_record')) + self.assertTrue(callable(self.annotation.create_history_record)) + + def test_create_history_record_returns_history_record(self): + self.annotation.plotter = aspecd.plotting.Plotter() + history_record = self.annotation.create_history_record() + self.assertTrue(isinstance(history_record, + aspecd.history.DatasetAnnotationHistoryRecord)) + diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 522b525..9052ae1 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -416,7 +416,7 @@ def test_annotate_adds_annotation_record(self): def test_added_annotation_record_is_annotationhistoryrecord(self): self.dataset.annotate(self.annotation) self.assertTrue(isinstance(self.dataset.annotations[-1], - aspecd.history.AnnotationHistoryRecord)) + aspecd.history.DatasetAnnotationHistoryRecord)) def test_has_delete_annotation_method(self): self.assertTrue(hasattr(self.dataset, 'delete_annotation')) @@ -454,7 +454,7 @@ def test_added_task_has_kind_annotation(self): def test_added_task_has_annotation_history_record(self): self.dataset.annotate(self.annotation) self.assertIsInstance(self.dataset.tasks[0]['task'], - aspecd.history.AnnotationHistoryRecord) + aspecd.history.DatasetAnnotationHistoryRecord) class TestDatasetPlotting(unittest.TestCase): diff --git a/tests/test_history.py b/tests/test_history.py index d297e19..b78c4e3 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -385,27 +385,27 @@ def test_replay(self): self.historyrecord.replay(dataset.Dataset()) -class TestAnnotationRecord(unittest.TestCase): +class TestDatasetAnnotationRecord(unittest.TestCase): def setUp(self): self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation_record = \ - aspecd.history.AnnotationRecord(self.annotation) + aspecd.history.DatasetAnnotationRecord(self.annotation) def test_instantiate_class(self): aspecd.annotation.DatasetAnnotation() def test_instantiate_class_with_annotation(self): - aspecd.history.AnnotationRecord(self.annotation) + aspecd.history.DatasetAnnotationRecord(self.annotation) def test_instantiate_content_from_annotation(self): self.annotation.content = {'foo': 'bar'} annotation_record = \ - aspecd.history.AnnotationRecord(self.annotation) + aspecd.history.DatasetAnnotationRecord(self.annotation) self.assertEqual(annotation_record.content, self.annotation.content) def test_instantiate_class_name_from_annotation(self): annotation_record = \ - aspecd.history.AnnotationRecord(self.annotation) + aspecd.history.DatasetAnnotationRecord(self.annotation) self.assertEqual(annotation_record.class_name, aspecd.utils.full_class_name(self.annotation)) @@ -421,7 +421,8 @@ def test_has_create_annotation_method(self): def test_create_annotation_returns_annotation_object(self): test_object = self.annotation_record.create_annotation() - self.assertTrue(isinstance(test_object, aspecd.annotation.DatasetAnnotation)) + self.assertTrue(isinstance(test_object, + aspecd.annotation.DatasetAnnotation)) def test_annotation_object_has_correct_contents_value(self): self.annotation_record.content = {'foo': 'bar'} @@ -435,27 +436,28 @@ def test_has_to_dict_method(self): def test_from_dict(self): orig_dict = self.annotation_record.to_dict() orig_dict["content"]["comment"] = 'foo' - new_annotation_record = aspecd.history.AnnotationRecord() + new_annotation_record = aspecd.history.DatasetAnnotationRecord() new_annotation_record.from_dict(orig_dict) self.assertDictEqual(orig_dict["content"], new_annotation_record.to_dict()["content"]) -class TestAnnotationHistoryRecord(unittest.TestCase): +class TestDatasetAnnotationHistoryRecord(unittest.TestCase): def setUp(self): self.annotation = aspecd.annotation.DatasetAnnotation() - self.annotation_record = aspecd.history.AnnotationHistoryRecord( - annotation=self.annotation) + self.annotation_record = \ + aspecd.history.DatasetAnnotationHistoryRecord( + annotation=self.annotation) def test_instantiate_class(self): pass def test_instantiate_class_with_package_name(self): - aspecd.history.AnnotationHistoryRecord( + aspecd.history.DatasetAnnotationHistoryRecord( annotation=self.annotation, package="numpy") def test_instantiate_class_with_package_name_sets_sysinfo(self): - annotation_step = aspecd.history.AnnotationHistoryRecord( + annotation_step = aspecd.history.DatasetAnnotationHistoryRecord( annotation=self.annotation, package="numpy") self.assertTrue("numpy" in annotation_step.sysinfo.packages.keys()) @@ -464,7 +466,7 @@ def test_has_annotation_property(self): def test_annotation_is_annotation_record(self): self.assertTrue(isinstance(self.annotation_record.annotation, - aspecd.history.AnnotationRecord)) + aspecd.history.DatasetAnnotationRecord)) class TestPlotRecord(unittest.TestCase): From 0fe5fbaccc98dd1561bb83564c98ddd222d52ffb Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 10:37:01 +0200 Subject: [PATCH 36/55] Plot annotation sort of works Plan: Kick out PlotAnnotationRecord and PlotAnnotationHistoryRecord --- VERSION | 2 +- aspecd/annotation.py | 86 +++++++++++++++++++-- aspecd/dataset.py | 2 +- aspecd/history.py | 160 ++++++++++++++++++++++++++++++++++----- aspecd/plotting.py | 33 ++++++++ tests/test_annotation.py | 35 +++++++-- tests/test_dataset.py | 8 +- tests/test_history.py | 97 +++++++++++++++++++++++- tests/test_plotting.py | 37 ++++++++- 9 files changed, 420 insertions(+), 40 deletions(-) diff --git a/VERSION b/VERSION index d5f3302..df38fcc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev61 +0.9.0.dev62 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index eb2c7b1..e485ace 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -17,7 +17,10 @@ dataset, such as comments stored in the metadata written during data acquisition. Hence, those comments do *not* belong to the metadata part of a dataset, but to the annotations in form of a -:obj:`aspecd.annotation.Comment` object. + +* :obj:`aspecd.annotation.Comment` + +object. Other frequent types of annotations are artefacts and characteristics, for which dedicated classes are available within the ASpecD framework, are: @@ -32,7 +35,18 @@ Plot(ter) annotations ===================== -TBD +Similar to datasets, plots, *i.e.* graphical representations of the data of +one or multiple datasets, can be annotated as well. Plot annotations will +always result in a graphical object of some kind added to the plot created +by a :class:`aspecd.plotting.Plotter`. Additionally, each plotter has a list +of annotations attached to it. + +All plot annotations inherit from the :class:`aspecd.annotation.PlotAnnotation` +base class. + +Concrete plot annotations are: + +* :class:`aspecd.annotation.VerticalLine` Module documentation @@ -254,24 +268,53 @@ class PlotAnnotation(ToDictMixin): Whereas many processing steps of data can be fully automated, annotations are mostly the domain of human interaction, looking at the graphical representation of the data of a dataset and providing some sort of - comments, trying to make sense of the data. + comments, trying to make sense of the data. Often, being able to add + some kind of annotation to these graphical representations is both, + tremendously helpful and required for further analysis. - Annotations can have different types, such as ... + Annotations can have different types, such as horizontal and vertical + lines added to a plot for comparing different data. Each of these types is represented by a class on its own that is derived from the :class:`PlotAnnotation` base class. Additionally, the type is reflected in the "type" property that gets set automatically to the class name in lower-case letters. + While generally, it should not matter whether a plot annotation gets + added to the plotter object before or after the actual plotting process, + adding the graphical elements annotations consist eventually of to the + plot is only possible once the :meth:`aspecd.plotting.Plotter.plot` + method has been called and the respective + :attr:`aspecd.plotting.Plotter.figure` and + :attr:`aspecd.plotting.Plotter.axes` attributes are set. To this end, + a plot annotation will only actually add graphical elements if the plot + exists already, and the plotter will in turn add any annotations added + prior to plotting when its :meth:`aspecd.plotting.Plotter.plot` method + is called. This avoids side effects, as annotating a plotter does *not* + create a graphical representation that did not exist before. + Attributes ---------- plotter : :class:`aspecd.plotting.Plotter` + Plotter the annotation belongs to type : :class:`str` + Textual description of the type of annotation: lowercase class name + + Set automatically, don't change parameters : :class:`dict` + All parameters necessary for the annotation, implicit and explicit properties : :class:`None` + Properties of the annotation, defining its appearance + + drawings : :class:`list` + Actual graphical representations of the annotation within the plot + + + .. versionadded:: 0.9 + """ def __init__(self): @@ -280,9 +323,10 @@ def __init__(self): self.type = self.__class__.__name__.lower() self.parameters = {} self.properties = None - self._exclude_from_to_dict = ['plotter', 'type'] + self.drawings = [] + self._exclude_from_to_dict = ['plotter', 'type', 'drawings'] - def annotate(self, plotter=None): + def annotate(self, plotter=None, from_plotter=False): """ Annotate a plot(ter) with the given annotation. @@ -298,12 +342,21 @@ def annotate(self, plotter=None): plotter : :class:`aspecd.plotting.Plotter` Plot(ter) to annotate + from_plotter : :class:`bool` + whether we are called from within a plotter + + Defaults to "False" and shall never be set manually. + Returns ------- plotter : :class:`aspecd.plotting.Plotter` Plotter that has been annotated """ + self._assign_plotter(plotter) + self._call_from_plotter(from_plotter) + if self.plotter.figure: + self._perform_task() return self.plotter def create_history_record(self): @@ -324,3 +377,24 @@ def create_history_record(self): history_record = aspecd.history.PlotAnnotationHistoryRecord( annotation=self, package=aspecd.utils.package_name(self.plotter)) return history_record + + def _assign_plotter(self, plotter): + if not plotter: + if not self.plotter: + raise aspecd.exceptions.MissingPlotterError + else: + self.plotter = plotter + + def _call_from_plotter(self, from_plotter): + if not from_plotter: + self.plotter.annotate(self) + + def _perform_task(self): + pass + + +class VerticalLine(PlotAnnotation): + + def _perform_task(self): + line = self.plotter.ax.axvline(x=self.parameters['positions'][0]) + self.drawings.append(line) diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 4554564..409cf30 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -496,7 +496,7 @@ def annotate(self, annotation_=None): Parameters ---------- - annotation_ : :obj:`aspecd.annotation.Annotation` + annotation_ : :obj:`aspecd.annotation.DatasetAnnotation` annotation to add to the dataset """ diff --git a/aspecd/history.py b/aspecd/history.py index 2cfd2fc..9d148a1 100644 --- a/aspecd/history.py +++ b/aspecd/history.py @@ -530,18 +530,19 @@ class DatasetAnnotationRecord(aspecd.utils.ToDictMixin): """Base class for annotation records stored in the dataset annotations. The annotation of a :class:`aspecd.dataset.Dataset` should *not* contain - references to :class:`aspecd.annotation.Annotation` objects, but rather - records that contain all necessary information to create the respective - objects inherited from :class:`aspecd.annotation.Annotation`. One - reason for this is simply that we want to import datasets containing - annotations in their analyses for which no corresponding annotation - class exists in the current installation of the application. Another is - to not have an infinite recursion of datasets, as the dataset is stored - in an :obj:`aspecd.annotation.Annotation` object. + references to :class:`aspecd.annotation.DatasetAnnotation` objects, + but rather records that contain all necessary information to create the + respective objects inherited from + :class:`aspecd.annotation.DatasetAnnotation`. One reason for this is + simply that we want to import datasets containing annotations in their + analyses for which no corresponding annotation class exists in the + current installation of the application. Another is to not have an + infinite recursion of datasets, as the dataset is stored in an + :obj:`aspecd.annotation.DatasetAnnotation` object. .. note:: Each annotation entry in a dataset stores the annotation as a - :class:`aspecd.history.AnnotationRecord`, even in applications + :class:`aspecd.history.DatasetAnnotationRecord`, even in applications inheriting from the ASpecD framework. Hence, subclassing of this class should normally not be necessary. @@ -551,6 +552,7 @@ class exists in the current installation of the application. Another is Actual content of the annotation Generic place for more information + class_name : :class:`str` Fully qualified name of the class of the corresponding annotation @@ -560,7 +562,7 @@ class exists in the current installation of the application. Another is Parameters ---------- - annotation : :class:`aspecd.annotation.Annotation` + annotation : :class:`aspecd.annotation.DatasetAnnotation` Annotation the record should be created for. Raises @@ -588,7 +590,7 @@ def from_annotation(self, annotation): Parameters ---------- - annotation : :obj:`aspecd.annotation.Annotation` + annotation : :obj:`aspecd.annotation.DatasetAnnotation` Object to obtain information from """ @@ -597,12 +599,12 @@ def from_annotation(self, annotation): self.class_name = aspecd.utils.full_class_name(annotation) def create_annotation(self): - """Create an analysis step object from the parameters stored. + """Create an annotation object from the parameters stored. Returns ------- - analysis_step : :class:`aspecd.analysis.SingleAnalysisStep` - actual analysis step object that can be used for analysis + annotation : :class:`aspecd.annotation.DatasetAnnotation` + actual annotation object that can be used for annotation """ annotation = aspecd.utils.object_from_class_name(self.class_name) @@ -628,16 +630,16 @@ class are set accordingly. class DatasetAnnotationHistoryRecord(HistoryRecord): - """History record for annotations of datasets or plots. + """History record for annotations of datasets. Attributes ---------- - annotation : :class:`aspecd.annotation.Annotation` + annotation : :class:`aspecd.annotation.DatasetAnnotation` Annotation the history is saved for Parameters ---------- - annotation : :class:`aspecd.annotation.AnnotationRecord` + annotation : :class:`aspecd.annotation.DatasetAnnotationRecord` Annotation the history is saved for package : :class:`str` @@ -650,6 +652,130 @@ def __init__(self, annotation=None, package=''): self.annotation = DatasetAnnotationRecord(annotation) +class PlotAnnotationRecord(aspecd.utils.ToDictMixin): + """Base class for annotation records stored in the plot annotations. + + The annotation of a :class:`aspecd.plotting.Plotter` should *not* contain + references to :class:`aspecd.annotation.PlotAnnotation` objects, but rather + records that contain all necessary information to create the respective + objects inherited from :class:`aspecd.annotation.PlotAnnotation`. One + reason for this is simply that we want to import datasets containing + annotations in their plotters for which no corresponding annotation + class exists in the current installation of the application. Another is + to not have an infinite recursion of datasets, as the dataset is stored + in an :obj:`aspecd.annotation.PlotAnnotation` object. + + .. note:: + Each annotation entry in a dataset stores the annotation as a + :class:`aspecd.history.PlotAnnotationRecord`, even in applications + inheriting from the ASpecD framework. Hence, subclassing of this class + should normally not be necessary. + + Attributes + ---------- + parameters : :class:`dict` + Parameters of the annotation + + properties : :class:`aspecd.utils.Properties` + Properties of the annotation + + An example would be line properties stored in an object of class + :class:`aspecd.plotting.LineProperties`. + + class_name : :class:`str` + Fully qualified name of the class of the corresponding annotation + + type : :class:`str` + Type of the annotation, usually similar to the class name but + human-readable and useful, *e.g.*, in reports. + + Parameters + ---------- + annotation : :class:`aspecd.annotation.PlotAnnotation` + Annotation the record should be created for. + + + .. versionadded:: 0.9 + + """ + + def __init__(self, annotation=None): + super().__init__() + self.class_name = '' + self.parameters = {} + self.properties = None + self._attributes_to_copy = ['parameters', 'properties', 'type'] + if annotation: + self.from_annotation(annotation) + + def from_annotation(self, annotation=None): + """Obtain information from annotation. + + Parameters + ---------- + annotation : :obj:`aspecd.annotation.PlotAnnotation` + Object to obtain information from + + """ + for attribute in self._attributes_to_copy: + setattr(self, attribute, getattr(annotation, attribute)) + self.class_name = aspecd.utils.full_class_name(annotation) + + def create_annotation(self): + """Create an annotation object from the parameters stored. + + Returns + ------- + annotation : :class:`aspecd.annotation.PlotAnnotation` + actual annotation object that can be used for annotation + + """ + annotation = aspecd.utils.object_from_class_name(self.class_name) + for attribute in self._attributes_to_copy: + setattr(annotation, attribute, getattr(self, attribute)) + return annotation + + def from_dict(self, dict_=None): + """ + Set properties from dictionary. + + Only parameters in the dictionary that are valid properties of the + class are set accordingly. + + Parameters + ---------- + dict_ : :class:`dict` + Dictionary containing properties to set + + """ + for key, value in dict_.items(): + if hasattr(self, key): + setattr(self, key, value) + + +class PlotAnnotationHistoryRecord(HistoryRecord): + """History record for annotations of plots. + + Attributes + ---------- + annotation : :class:`aspecd.annotation.PlotAnnotation` + Annotation the history is saved for + + Parameters + ---------- + annotation : :class:`aspecd.annotation.PlotAnnotationRecord` + Annotation the history is saved for + + package : :class:`str` + Name of package the history record gets recorded for + + """ + + def __init__(self, annotation=None, package=''): + super().__init__(package=package) + self.annotation = PlotAnnotationRecord(annotation) + + class PlotRecord(aspecd.utils.ToDictMixin): """Base class for records storing information about a plot. diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 20743c6..07a40af 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -760,6 +760,7 @@ def __init__(self): self.label = '' self.style = '' self.comment = '' + self.annotations = [] super().__init__() # self._original_rcparams = None @@ -811,6 +812,38 @@ def applicable(data): # pylint: disable=unused-argument """ return True + def annotate(self, annotation=None): + """Add annotation to dataset. + + Parameters + ---------- + annotation : :obj:`aspecd.annotation.PlotAnnotation` + Annotation to add to the plotter + + Returns + ------- + annotation : :class:`aspecd.annotation.PlotAnnotation` + Annotation added to the plot(ter) + + """ + # Important: Need a copy, not the reference to the original object + annotation = copy.deepcopy(annotation) + annotation.annotate(self, from_plotter=True) + history_record = annotation.create_history_record() + self.annotations.append(history_record) + return annotation + + def delete_annotation(self, index=None): + """Remove annotation record from dataset. + + Parameters + ---------- + index : `int` + Number of analysis in analyses to delete + + """ + del self.annotations[index] + def _set_style(self): self._original_rcparams = mpl.rcParams.copy() if self.style: diff --git a/tests/test_annotation.py b/tests/test_annotation.py index a51103f..5ff6fe6 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -100,8 +100,8 @@ def test_has_create_history_record_method(self): def test_create_history_record_returns_history_record(self): self.annotation.dataset = aspecd.dataset.Dataset() history_record = self.annotation.create_history_record() - self.assertTrue(isinstance(history_record, - aspecd.history.DatasetAnnotationHistoryRecord)) + self.assertIsInstance(history_record, + aspecd.history.DatasetAnnotationHistoryRecord) class TestComment(unittest.TestCase): @@ -151,7 +151,6 @@ def test_instantiate_class(self): pass -# @unittest.skip class TestPlotAnnotation(unittest.TestCase): def setUp(self): self.annotation = aspecd.annotation.PlotAnnotation() @@ -179,12 +178,16 @@ def test_has_parameters_property(self): def test_has_properties_property(self): self.assertTrue(hasattr(self.annotation, 'properties')) + def test_has_drawings_property(self): + self.assertTrue(hasattr(self.annotation, 'drawings')) + self.assertIsInstance(self.annotation.drawings, list) + def test_has_to_dict_method(self): self.assertTrue(hasattr(self.annotation, 'to_dict')) self.assertTrue(callable(self.annotation.to_dict)) def test_to_dict_does_not_contain_certain_keys(self): - for key in ['plotter', 'type']: + for key in ['plotter', 'type', 'drawings']: with self.subTest(key=key): self.assertNotIn(key, self.annotation.to_dict()) @@ -211,9 +214,10 @@ def test_annotate_without_argument_nor_plotter_raises(self): with self.assertRaises(aspecd.exceptions.MissingPlotterError): self.annotation.annotate() - def test_annotate_with_empty_content_raises(self): + @unittest.skip + def test_annotate_with_empty_parameters_raises(self): self.annotation.plotter = aspecd.plotting.Plotter() - self.annotation.content.clear() + self.annotation.parameters.clear() with self.assertRaises(aspecd.exceptions.NoContentError): self.annotation.annotate() @@ -228,6 +232,21 @@ def test_has_create_history_record_method(self): def test_create_history_record_returns_history_record(self): self.annotation.plotter = aspecd.plotting.Plotter() history_record = self.annotation.create_history_record() - self.assertTrue(isinstance(history_record, - aspecd.history.DatasetAnnotationHistoryRecord)) + self.assertIsInstance(history_record, + aspecd.history.PlotAnnotationHistoryRecord) + + +class TestVerticalLine(unittest.TestCase): + def setUp(self): + self.annotation = aspecd.annotation.VerticalLine() + self.plotter = aspecd.plotting.Plotter() + + def test_instantiate_class(self): + pass + def test_annotate_adds_line_to_plotter(self): + self.annotation.parameters['positions'] = [.5] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertIn(annotation.drawings[0], + self.plotter.ax.get_children()) diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 9052ae1..8aa0069 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -413,16 +413,16 @@ def test_annotate_adds_annotation_record(self): self.dataset.annotate(self.annotation) self.assertFalse(self.dataset.annotations == []) - def test_added_annotation_record_is_annotationhistoryrecord(self): + def test_added_annotation_record_is_datasetannotationhistoryrecord(self): self.dataset.annotate(self.annotation) - self.assertTrue(isinstance(self.dataset.annotations[-1], - aspecd.history.DatasetAnnotationHistoryRecord)) + self.assertIsInstance(self.dataset.annotations[-1], + aspecd.history.DatasetAnnotationHistoryRecord) def test_has_delete_annotation_method(self): self.assertTrue(hasattr(self.dataset, 'delete_annotation')) self.assertTrue(callable(self.dataset.delete_annotation)) - def test_delete_annotation_deletes_analysis_record(self): + def test_delete_annotation_deletes_annotation_record(self): self.dataset.annotate(self.annotation) orig_len_annotations = len(self.dataset.annotations) self.dataset.delete_annotation(0) diff --git a/tests/test_history.py b/tests/test_history.py index b78c4e3..4e871df 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -465,8 +465,101 @@ def test_has_annotation_property(self): self.assertTrue(hasattr(self.annotation_record, 'annotation')) def test_annotation_is_annotation_record(self): - self.assertTrue(isinstance(self.annotation_record.annotation, - aspecd.history.DatasetAnnotationRecord)) + self.assertIsInstance(self.annotation_record.annotation, + aspecd.history.DatasetAnnotationRecord) + + +class TestPlotAnnotationRecord(unittest.TestCase): + def setUp(self): + self.annotation = aspecd.annotation.PlotAnnotation() + self.annotation_record = \ + aspecd.history.PlotAnnotationRecord(self.annotation) + + def test_instantiate_class(self): + aspecd.annotation.PlotAnnotation() + + def test_instantiate_class_with_annotation(self): + aspecd.history.PlotAnnotationRecord(self.annotation) + + def test_instantiate_parameters_from_annotation(self): + self.annotation.parameters = {'foo': 'bar'} + annotation_record = aspecd.history.PlotAnnotationRecord(self.annotation) + self.assertEqual(annotation_record.parameters, + self.annotation.parameters) + + def test_instantiate_properties_from_annotation(self): + self.annotation.properties = {'foo': 'bar'} + annotation_record = aspecd.history.PlotAnnotationRecord(self.annotation) + self.assertEqual(annotation_record.properties, + self.annotation.properties) + + def test_instantiate_class_name_from_annotation(self): + annotation_record = aspecd.history.PlotAnnotationRecord(self.annotation) + self.assertEqual(annotation_record.class_name, + aspecd.utils.full_class_name(self.annotation)) + + def test_has_from_annotation_method(self): + self.assertTrue(hasattr(self.annotation_record, 'from_annotation')) + self.assertTrue(callable(self.annotation_record.from_annotation)) + + def test_has_create_annotation_method(self): + self.assertTrue(hasattr(self.annotation_record, 'create_annotation')) + self.assertTrue(callable(self.annotation_record.create_annotation)) + + def test_create_annotation_returns_annotation_object(self): + test_object = self.annotation_record.create_annotation() + self.assertTrue(isinstance(test_object, + aspecd.annotation.PlotAnnotation)) + + def test_annotation_object_has_correct_parameters(self): + self.annotation_record.parameters = {'foo': 'bar'} + test_object = self.annotation_record.create_annotation() + self.assertEqual(self.annotation_record.parameters, + test_object.parameters) + + def test_annotation_object_has_correct_properties(self): + self.annotation_record.properties = {'foo': 'bar'} + test_object = self.annotation_record.create_annotation() + self.assertEqual(self.annotation_record.properties, + test_object.properties) + + def test_has_to_dict_method(self): + self.assertTrue(hasattr(self.annotation_record, 'to_dict')) + self.assertTrue(callable(self.annotation_record.to_dict)) + + def test_from_dict(self): + orig_dict = self.annotation_record.to_dict() + orig_dict["parameters"]["foo"] = 'bar' + new_annotation_record = aspecd.history.PlotAnnotationRecord() + new_annotation_record.from_dict(orig_dict) + self.assertDictEqual(orig_dict["parameters"], + new_annotation_record.to_dict()["parameters"]) + + +class TestPlotAnnotationHistoryRecord(unittest.TestCase): + def setUp(self): + self.annotation = aspecd.annotation.PlotAnnotation() + self.annotation_record = aspecd.history.PlotAnnotationHistoryRecord( + annotation=self.annotation) + + def test_instantiate_class(self): + pass + + def test_instantiate_class_with_package_name(self): + aspecd.history.PlotAnnotationHistoryRecord( + annotation=self.annotation, package="numpy") + + def test_instantiate_class_with_package_name_sets_sysinfo(self): + annotation_step = aspecd.history.PlotAnnotationHistoryRecord( + annotation=self.annotation, package="numpy") + self.assertTrue("numpy" in annotation_step.sysinfo.packages.keys()) + + def test_has_annotation_property(self): + self.assertTrue(hasattr(self.annotation_record, 'annotation')) + + def test_annotation_is_annotation_record(self): + self.assertIsInstance(self.annotation_record.annotation, + aspecd.history.PlotAnnotationRecord) class TestPlotRecord(unittest.TestCase): diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 2a43299..24c7d5d 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -16,7 +16,7 @@ from unittest.mock import MagicMock, patch import aspecd.exceptions -from aspecd import plotting, utils, dataset +from aspecd import plotting, utils, dataset, annotation, history class TestPlotter(unittest.TestCase): @@ -218,6 +218,41 @@ def test_plot_sets_tight_layout(self): def test_plot_has_device_data_parameter(self): self.assertIn('device_data', self.plotter.parameters) + def test_has_annotate_method(self): + self.assertTrue(hasattr(self.plotter, 'annotate')) + self.assertTrue(callable(self.plotter.annotate)) + + def test_annotate_adds_annotation_record(self): + self.plotter.annotate(aspecd.annotation.PlotAnnotation()) + self.assertTrue(self.plotter.annotations) + + def test_added_annotation_record_is_plotannotationhistoryrecord(self): + self.plotter.annotate(aspecd.annotation.PlotAnnotation()) + self.assertIsInstance(self.plotter.annotations[-1], + aspecd.history.PlotAnnotationHistoryRecord) + + def test_annotate_returns_annotation_object(self): + annotation = self.plotter.annotate(aspecd.annotation.PlotAnnotation()) + self.assertIsInstance(annotation, aspecd.annotation.PlotAnnotation) + + def test_has_delete_annotation_method(self): + self.assertTrue(hasattr(self.plotter, 'delete_annotation')) + self.assertTrue(callable(self.plotter.delete_annotation)) + + def test_delete_annotation_deletes_annotation_record(self): + self.plotter.annotate(aspecd.annotation.PlotAnnotation()) + orig_len_annotations = len(self.plotter.annotations) + self.plotter.delete_annotation(0) + new_len_annotations = len(self.plotter.annotations) + self.assertGreater(orig_len_annotations, new_len_annotations) + + def test_delete_annotation_deletes_correct_annotation_record(self): + self.plotter.annotate(aspecd.annotation.PlotAnnotation()) + self.plotter.annotate(aspecd.annotation.PlotAnnotation()) + annotation_step = self.plotter.annotations[-1] + self.plotter.delete_annotation(0) + self.assertIs(annotation_step, self.plotter.annotations[-1]) + class TestSinglePlotter(unittest.TestCase): def setUp(self): From a68ed1973206da449ee42fe0727964812e13289a Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 10:59:50 +0200 Subject: [PATCH 37/55] Plot annotation sort of works (again) Next steps: properties of annotations; plotannotation task in recipe-driven data analysis --- VERSION | 2 +- aspecd/annotation.py | 39 +++++------- aspecd/dataset.py | 4 +- aspecd/history.py | 134 ++------------------------------------- aspecd/plotting.py | 14 +++- docs/changelog.rst | 4 ++ tests/test_annotation.py | 25 ++++---- tests/test_dataset.py | 4 +- tests/test_history.py | 111 +++----------------------------- tests/test_plotting.py | 4 +- 10 files changed, 68 insertions(+), 273 deletions(-) diff --git a/VERSION b/VERSION index df38fcc..24b0e07 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev62 +0.9.0.dev63 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index e485ace..73552c7 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -29,9 +29,11 @@ * :class:`aspecd.annotation.Characteristic`. For other types of annotations, simply subclass the -:class:`aspecd.annotation.Annotation` base class. +:class:`aspecd.annotation.DatasetAnnotation` base class. +.. _:sec:annotation:plot: + Plot(ter) annotations ===================== @@ -41,6 +43,18 @@ by a :class:`aspecd.plotting.Plotter`. Additionally, each plotter has a list of annotations attached to it. +While generally, it should not matter whether a plot annotation gets added +to the plotter object before or after the actual plotting process, adding +the graphical elements annotations consist eventually of to the plot is only +possible once the :meth:`aspecd.plotting.Plotter.plot` method has been +called and the respective :attr:`aspecd.plotting.Plotter.figure` and +:attr:`aspecd.plotting.Plotter.axes` attributes are set. To this end, a plot +annotation will only actually add graphical elements if the plot exists +already, and the plotter will in turn add any annotations added prior to +plotting when its :meth:`aspecd.plotting.Plotter.plot` method is called. +This avoids side effects, as annotating a plotter does *not* create a +graphical representation that did not exist before. + All plot annotations inherit from the :class:`aspecd.annotation.PlotAnnotation` base class. @@ -196,11 +210,11 @@ def create_history_record(self): Returns ------- - history_record : :class:`aspecd.history.DatasetAnnotationHistoryRecord` + history_record : :class:`aspecd.history.AnnotationHistoryRecord` history record for annotation step """ - history_record = aspecd.history.DatasetAnnotationHistoryRecord( + history_record = aspecd.history.AnnotationHistoryRecord( annotation=self, package=self.dataset.package_name) return history_record @@ -359,25 +373,6 @@ def annotate(self, plotter=None, from_plotter=False): self._perform_task() return self.plotter - def create_history_record(self): - """ - Create history record to be added to the plotter. - - Usually, this method gets called from within the - :meth:`aspecd.plotting.Plotter.annotate` method of the - :class:`aspecd.plotting.Plotter` class and ensures the history of - each annotation step to get written properly. - - Returns - ------- - history_record : :class:`aspecd.history.PlotAnnotationHistoryRecord` - history record for annotation step - - """ - history_record = aspecd.history.PlotAnnotationHistoryRecord( - annotation=self, package=aspecd.utils.package_name(self.plotter)) - return history_record - def _assign_plotter(self, plotter): if not plotter: if not self.plotter: diff --git a/aspecd/dataset.py b/aspecd/dataset.py index 409cf30..a5909e4 100644 --- a/aspecd/dataset.py +++ b/aspecd/dataset.py @@ -767,7 +767,7 @@ class are set accordingly. elif key == "annotations": for element in dict_[key]: record = \ - aspecd.history.DatasetAnnotationHistoryRecord() + aspecd.history.AnnotationHistoryRecord() record.from_dict(element) self.annotations.append(record) elif key == "representations": @@ -787,7 +787,7 @@ class are set accordingly. 'aspecd.history.PlotHistoryRecord' elif element["kind"] == "annotation": record_class_name = \ - 'aspecd.history.DatasetAnnotationHistoryRecord' + 'aspecd.history.AnnotationHistoryRecord' else: record_class_name = 'aspecd.history.' \ + element["kind"].capitalize() + 'HistoryRecord' diff --git a/aspecd/history.py b/aspecd/history.py index 9d148a1..d7a565f 100644 --- a/aspecd/history.py +++ b/aspecd/history.py @@ -526,7 +526,7 @@ def replay(self, dataset): dataset.analyse(analysis_step=analysis_step) -class DatasetAnnotationRecord(aspecd.utils.ToDictMixin): +class AnnotationRecord(aspecd.utils.ToDictMixin): """Base class for annotation records stored in the dataset annotations. The annotation of a :class:`aspecd.dataset.Dataset` should *not* contain @@ -542,7 +542,7 @@ class DatasetAnnotationRecord(aspecd.utils.ToDictMixin): .. note:: Each annotation entry in a dataset stores the annotation as a - :class:`aspecd.history.DatasetAnnotationRecord`, even in applications + :class:`aspecd.history.AnnotationRecord`, even in applications inheriting from the ASpecD framework. Hence, subclassing of this class should normally not be necessary. @@ -629,7 +629,7 @@ class are set accordingly. setattr(self, key, value) -class DatasetAnnotationHistoryRecord(HistoryRecord): +class AnnotationHistoryRecord(HistoryRecord): """History record for annotations of datasets. Attributes @@ -639,7 +639,7 @@ class DatasetAnnotationHistoryRecord(HistoryRecord): Parameters ---------- - annotation : :class:`aspecd.annotation.DatasetAnnotationRecord` + annotation : :class:`aspecd.annotation.AnnotationRecord` Annotation the history is saved for package : :class:`str` @@ -649,131 +649,7 @@ class DatasetAnnotationHistoryRecord(HistoryRecord): def __init__(self, annotation=None, package=''): super().__init__(package=package) - self.annotation = DatasetAnnotationRecord(annotation) - - -class PlotAnnotationRecord(aspecd.utils.ToDictMixin): - """Base class for annotation records stored in the plot annotations. - - The annotation of a :class:`aspecd.plotting.Plotter` should *not* contain - references to :class:`aspecd.annotation.PlotAnnotation` objects, but rather - records that contain all necessary information to create the respective - objects inherited from :class:`aspecd.annotation.PlotAnnotation`. One - reason for this is simply that we want to import datasets containing - annotations in their plotters for which no corresponding annotation - class exists in the current installation of the application. Another is - to not have an infinite recursion of datasets, as the dataset is stored - in an :obj:`aspecd.annotation.PlotAnnotation` object. - - .. note:: - Each annotation entry in a dataset stores the annotation as a - :class:`aspecd.history.PlotAnnotationRecord`, even in applications - inheriting from the ASpecD framework. Hence, subclassing of this class - should normally not be necessary. - - Attributes - ---------- - parameters : :class:`dict` - Parameters of the annotation - - properties : :class:`aspecd.utils.Properties` - Properties of the annotation - - An example would be line properties stored in an object of class - :class:`aspecd.plotting.LineProperties`. - - class_name : :class:`str` - Fully qualified name of the class of the corresponding annotation - - type : :class:`str` - Type of the annotation, usually similar to the class name but - human-readable and useful, *e.g.*, in reports. - - Parameters - ---------- - annotation : :class:`aspecd.annotation.PlotAnnotation` - Annotation the record should be created for. - - - .. versionadded:: 0.9 - - """ - - def __init__(self, annotation=None): - super().__init__() - self.class_name = '' - self.parameters = {} - self.properties = None - self._attributes_to_copy = ['parameters', 'properties', 'type'] - if annotation: - self.from_annotation(annotation) - - def from_annotation(self, annotation=None): - """Obtain information from annotation. - - Parameters - ---------- - annotation : :obj:`aspecd.annotation.PlotAnnotation` - Object to obtain information from - - """ - for attribute in self._attributes_to_copy: - setattr(self, attribute, getattr(annotation, attribute)) - self.class_name = aspecd.utils.full_class_name(annotation) - - def create_annotation(self): - """Create an annotation object from the parameters stored. - - Returns - ------- - annotation : :class:`aspecd.annotation.PlotAnnotation` - actual annotation object that can be used for annotation - - """ - annotation = aspecd.utils.object_from_class_name(self.class_name) - for attribute in self._attributes_to_copy: - setattr(annotation, attribute, getattr(self, attribute)) - return annotation - - def from_dict(self, dict_=None): - """ - Set properties from dictionary. - - Only parameters in the dictionary that are valid properties of the - class are set accordingly. - - Parameters - ---------- - dict_ : :class:`dict` - Dictionary containing properties to set - - """ - for key, value in dict_.items(): - if hasattr(self, key): - setattr(self, key, value) - - -class PlotAnnotationHistoryRecord(HistoryRecord): - """History record for annotations of plots. - - Attributes - ---------- - annotation : :class:`aspecd.annotation.PlotAnnotation` - Annotation the history is saved for - - Parameters - ---------- - annotation : :class:`aspecd.annotation.PlotAnnotationRecord` - Annotation the history is saved for - - package : :class:`str` - Name of package the history record gets recorded for - - """ - - def __init__(self, annotation=None, package=''): - super().__init__(package=package) - self.annotation = PlotAnnotationRecord(annotation) + self.annotation = AnnotationRecord(annotation) class PlotRecord(aspecd.utils.ToDictMixin): diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 07a40af..c9754fb 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -720,6 +720,12 @@ class Plotter(aspecd.utils.ToDictMixin): comment : :class:`str` User-supplied comment describing intent, purpose, reason, ... + annotations : :class:`list` + List of annotations added to the plotter. + + Each annotation is an object of class + :class:`aspecd.annotation.PlotAnnotation`. + Raises ------ @@ -787,6 +793,7 @@ def plot(self): self._set_style() self._create_figure_and_axes() self._create_plot() + self._add_annotations() self.properties.apply(plotter=self) self._set_legend() self._add_zero_lines() @@ -829,8 +836,7 @@ def annotate(self, annotation=None): # Important: Need a copy, not the reference to the original object annotation = copy.deepcopy(annotation) annotation.annotate(self, from_plotter=True) - history_record = annotation.create_history_record() - self.annotations.append(history_record) + self.annotations.append(annotation) return annotation def delete_annotation(self, index=None): @@ -937,6 +943,10 @@ def _create_plot(self): """ + def _add_annotations(self): + for annotation in self.annotations: + annotation.annotate(self, from_plotter=True) + def save(self, saver=None): """Save the plot to a file. diff --git a/docs/changelog.rst b/docs/changelog.rst index a5430ac..5133549 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,10 @@ New features * Colorbar for 2D plotter + * Annotations for plots + + For details, see :ref:`the documentation of plot annotations <:sec:annotation:plot>`. + * Device data * New property :attr:`aspecd.dataset.Dataset.device_data` for storing additional/secondary (monitoring) data. diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 5ff6fe6..6275fe6 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -101,7 +101,7 @@ def test_create_history_record_returns_history_record(self): self.annotation.dataset = aspecd.dataset.Dataset() history_record = self.annotation.create_history_record() self.assertIsInstance(history_record, - aspecd.history.DatasetAnnotationHistoryRecord) + aspecd.history.AnnotationHistoryRecord) class TestComment(unittest.TestCase): @@ -225,16 +225,6 @@ def test_annotate_returns_plotter(self): test_plotter = self.annotation.annotate(aspecd.plotting.Plotter()) self.assertTrue(isinstance(test_plotter, aspecd.plotting.Plotter)) - def test_has_create_history_record_method(self): - self.assertTrue(hasattr(self.annotation, 'create_history_record')) - self.assertTrue(callable(self.annotation.create_history_record)) - - def test_create_history_record_returns_history_record(self): - self.annotation.plotter = aspecd.plotting.Plotter() - history_record = self.annotation.create_history_record() - self.assertIsInstance(history_record, - aspecd.history.PlotAnnotationHistoryRecord) - class TestVerticalLine(unittest.TestCase): def setUp(self): @@ -250,3 +240,16 @@ def test_annotate_adds_line_to_plotter(self): annotation = self.plotter.annotate(self.annotation) self.assertIn(annotation.drawings[0], self.plotter.ax.get_children()) + + def test_annotate_adds_line_to_plotter_after_plotting(self): + self.annotation.parameters['positions'] = [.5] + annotation = self.plotter.annotate(self.annotation) + self.plotter.plot() + self.assertIn(annotation.drawings[0], + self.plotter.ax.get_children()) + + def test_annotate_before_plotting_does_not_add_annotation_twice(self): + self.annotation.parameters['positions'] = [.5] + self.plotter.annotate(self.annotation) + self.plotter.plot() + self.assertEqual(1, len(self.plotter.annotations)) diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 8aa0069..39c1f33 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -416,7 +416,7 @@ def test_annotate_adds_annotation_record(self): def test_added_annotation_record_is_datasetannotationhistoryrecord(self): self.dataset.annotate(self.annotation) self.assertIsInstance(self.dataset.annotations[-1], - aspecd.history.DatasetAnnotationHistoryRecord) + aspecd.history.AnnotationHistoryRecord) def test_has_delete_annotation_method(self): self.assertTrue(hasattr(self.dataset, 'delete_annotation')) @@ -454,7 +454,7 @@ def test_added_task_has_kind_annotation(self): def test_added_task_has_annotation_history_record(self): self.dataset.annotate(self.annotation) self.assertIsInstance(self.dataset.tasks[0]['task'], - aspecd.history.DatasetAnnotationHistoryRecord) + aspecd.history.AnnotationHistoryRecord) class TestDatasetPlotting(unittest.TestCase): diff --git a/tests/test_history.py b/tests/test_history.py index 4e871df..2faf312 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -389,23 +389,23 @@ class TestDatasetAnnotationRecord(unittest.TestCase): def setUp(self): self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation_record = \ - aspecd.history.DatasetAnnotationRecord(self.annotation) + aspecd.history.AnnotationRecord(self.annotation) def test_instantiate_class(self): aspecd.annotation.DatasetAnnotation() def test_instantiate_class_with_annotation(self): - aspecd.history.DatasetAnnotationRecord(self.annotation) + aspecd.history.AnnotationRecord(self.annotation) def test_instantiate_content_from_annotation(self): self.annotation.content = {'foo': 'bar'} annotation_record = \ - aspecd.history.DatasetAnnotationRecord(self.annotation) + aspecd.history.AnnotationRecord(self.annotation) self.assertEqual(annotation_record.content, self.annotation.content) def test_instantiate_class_name_from_annotation(self): annotation_record = \ - aspecd.history.DatasetAnnotationRecord(self.annotation) + aspecd.history.AnnotationRecord(self.annotation) self.assertEqual(annotation_record.class_name, aspecd.utils.full_class_name(self.annotation)) @@ -436,7 +436,7 @@ def test_has_to_dict_method(self): def test_from_dict(self): orig_dict = self.annotation_record.to_dict() orig_dict["content"]["comment"] = 'foo' - new_annotation_record = aspecd.history.DatasetAnnotationRecord() + new_annotation_record = aspecd.history.AnnotationRecord() new_annotation_record.from_dict(orig_dict) self.assertDictEqual(orig_dict["content"], new_annotation_record.to_dict()["content"]) @@ -446,18 +446,18 @@ class TestDatasetAnnotationHistoryRecord(unittest.TestCase): def setUp(self): self.annotation = aspecd.annotation.DatasetAnnotation() self.annotation_record = \ - aspecd.history.DatasetAnnotationHistoryRecord( + aspecd.history.AnnotationHistoryRecord( annotation=self.annotation) def test_instantiate_class(self): pass def test_instantiate_class_with_package_name(self): - aspecd.history.DatasetAnnotationHistoryRecord( + aspecd.history.AnnotationHistoryRecord( annotation=self.annotation, package="numpy") def test_instantiate_class_with_package_name_sets_sysinfo(self): - annotation_step = aspecd.history.DatasetAnnotationHistoryRecord( + annotation_step = aspecd.history.AnnotationHistoryRecord( annotation=self.annotation, package="numpy") self.assertTrue("numpy" in annotation_step.sysinfo.packages.keys()) @@ -466,100 +466,7 @@ def test_has_annotation_property(self): def test_annotation_is_annotation_record(self): self.assertIsInstance(self.annotation_record.annotation, - aspecd.history.DatasetAnnotationRecord) - - -class TestPlotAnnotationRecord(unittest.TestCase): - def setUp(self): - self.annotation = aspecd.annotation.PlotAnnotation() - self.annotation_record = \ - aspecd.history.PlotAnnotationRecord(self.annotation) - - def test_instantiate_class(self): - aspecd.annotation.PlotAnnotation() - - def test_instantiate_class_with_annotation(self): - aspecd.history.PlotAnnotationRecord(self.annotation) - - def test_instantiate_parameters_from_annotation(self): - self.annotation.parameters = {'foo': 'bar'} - annotation_record = aspecd.history.PlotAnnotationRecord(self.annotation) - self.assertEqual(annotation_record.parameters, - self.annotation.parameters) - - def test_instantiate_properties_from_annotation(self): - self.annotation.properties = {'foo': 'bar'} - annotation_record = aspecd.history.PlotAnnotationRecord(self.annotation) - self.assertEqual(annotation_record.properties, - self.annotation.properties) - - def test_instantiate_class_name_from_annotation(self): - annotation_record = aspecd.history.PlotAnnotationRecord(self.annotation) - self.assertEqual(annotation_record.class_name, - aspecd.utils.full_class_name(self.annotation)) - - def test_has_from_annotation_method(self): - self.assertTrue(hasattr(self.annotation_record, 'from_annotation')) - self.assertTrue(callable(self.annotation_record.from_annotation)) - - def test_has_create_annotation_method(self): - self.assertTrue(hasattr(self.annotation_record, 'create_annotation')) - self.assertTrue(callable(self.annotation_record.create_annotation)) - - def test_create_annotation_returns_annotation_object(self): - test_object = self.annotation_record.create_annotation() - self.assertTrue(isinstance(test_object, - aspecd.annotation.PlotAnnotation)) - - def test_annotation_object_has_correct_parameters(self): - self.annotation_record.parameters = {'foo': 'bar'} - test_object = self.annotation_record.create_annotation() - self.assertEqual(self.annotation_record.parameters, - test_object.parameters) - - def test_annotation_object_has_correct_properties(self): - self.annotation_record.properties = {'foo': 'bar'} - test_object = self.annotation_record.create_annotation() - self.assertEqual(self.annotation_record.properties, - test_object.properties) - - def test_has_to_dict_method(self): - self.assertTrue(hasattr(self.annotation_record, 'to_dict')) - self.assertTrue(callable(self.annotation_record.to_dict)) - - def test_from_dict(self): - orig_dict = self.annotation_record.to_dict() - orig_dict["parameters"]["foo"] = 'bar' - new_annotation_record = aspecd.history.PlotAnnotationRecord() - new_annotation_record.from_dict(orig_dict) - self.assertDictEqual(orig_dict["parameters"], - new_annotation_record.to_dict()["parameters"]) - - -class TestPlotAnnotationHistoryRecord(unittest.TestCase): - def setUp(self): - self.annotation = aspecd.annotation.PlotAnnotation() - self.annotation_record = aspecd.history.PlotAnnotationHistoryRecord( - annotation=self.annotation) - - def test_instantiate_class(self): - pass - - def test_instantiate_class_with_package_name(self): - aspecd.history.PlotAnnotationHistoryRecord( - annotation=self.annotation, package="numpy") - - def test_instantiate_class_with_package_name_sets_sysinfo(self): - annotation_step = aspecd.history.PlotAnnotationHistoryRecord( - annotation=self.annotation, package="numpy") - self.assertTrue("numpy" in annotation_step.sysinfo.packages.keys()) - - def test_has_annotation_property(self): - self.assertTrue(hasattr(self.annotation_record, 'annotation')) - - def test_annotation_is_annotation_record(self): - self.assertIsInstance(self.annotation_record.annotation, - aspecd.history.PlotAnnotationRecord) + aspecd.history.AnnotationRecord) class TestPlotRecord(unittest.TestCase): diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 24c7d5d..c14270d 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -226,10 +226,10 @@ def test_annotate_adds_annotation_record(self): self.plotter.annotate(aspecd.annotation.PlotAnnotation()) self.assertTrue(self.plotter.annotations) - def test_added_annotation_record_is_plotannotationhistoryrecord(self): + def test_added_annotation_record_is_plotannotation(self): self.plotter.annotate(aspecd.annotation.PlotAnnotation()) self.assertIsInstance(self.plotter.annotations[-1], - aspecd.history.PlotAnnotationHistoryRecord) + aspecd.annotation.PlotAnnotation) def test_annotate_returns_annotation_object(self): annotation = self.plotter.annotate(aspecd.annotation.PlotAnnotation()) From 4096fde290ba6bf3172e8e6d9c3cbe6762591c49 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 12:19:12 +0200 Subject: [PATCH 38/55] VerticalLine and HorizontalLine with properties Next step: plotannotation task in recipe-driven data analysis --- VERSION | 2 +- aspecd/annotation.py | 157 +++++++++++++++++++++++++++++++++++++-- tests/test_annotation.py | 121 ++++++++++++++++++++++++++++++ tests/test_plotting.py | 2 +- 4 files changed, 272 insertions(+), 10 deletions(-) diff --git a/VERSION b/VERSION index 24b0e07..bde33b5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev63 +0.9.0.dev64 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index 73552c7..df9e117 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -13,21 +13,32 @@ Dataset annotations =================== -The simplest form of an annotation is a comment applying to an entire -dataset, such as comments stored in the metadata written during data -acquisition. Hence, those comments do *not* belong to the metadata part of -a dataset, but to the annotations in form of a +All dataset annotations inherit from the +:class:`aspecd.annotation.DatasetAnnotation` base class. + +Concrete dataset annotations are: * :obj:`aspecd.annotation.Comment` -object. + The simplest form of an annotation is a comment applying to an entire + dataset, such as comments stored in the metadata written during data + acquisition. Hence, those comments do *not* belong to the metadata part of + a dataset, but are stored as an annotation using this class. Other frequent types of annotations are artefacts and characteristics, -for which dedicated classes are available within the ASpecD framework, are: +for which dedicated classes are available within the ASpecD framework: * :class:`aspecd.annotation.Artefact` * :class:`aspecd.annotation.Characteristic`. + +.. todo:: + + Flesh out these additional DatasetAnnotation classes, particularly in + light of the newly created PlotAnnotation classes that may eventually be + a way to graphically display the dataset annotations. + + For other types of annotations, simply subclass the :class:`aspecd.annotation.DatasetAnnotation` base class. @@ -62,6 +73,12 @@ * :class:`aspecd.annotation.VerticalLine` + Add vertical line(s) to a plot(ter). + +* :class:`aspecd.annotation.HorizontalLine` + + Add horizontal line(s) to a plot(ter). + Module documentation ==================== @@ -70,6 +87,7 @@ import aspecd.exceptions import aspecd.history +import aspecd.plotting from aspecd.utils import ToDictMixin @@ -371,6 +389,8 @@ def annotate(self, plotter=None, from_plotter=False): self._call_from_plotter(from_plotter) if self.plotter.figure: self._perform_task() + for drawing in self.drawings: + self.properties.apply(drawing=drawing) return self.plotter def _assign_plotter(self, plotter): @@ -389,7 +409,128 @@ def _perform_task(self): class VerticalLine(PlotAnnotation): + # noinspection PyUnresolvedReferences + """ + Vertical line(s) added to a plot. + + Vertical lines are often useful to compare peak positions or as a + general guide to the eye of the observer. + + The properties of the lines can be controlled in quite some detail using + the :attr:`properties` property. Note that all lines will share the same + properties. If you need to add lines with different properties to the + same plot, use several :class:`VerticalLine` objects and annotate + separately. + + Attributes + ---------- + parameters : :class:`dict` + All parameters necessary for the annotation, implicit and explicit + + The following keys exist: + + positions : :class:`list` + List of the positions vertical lines should appear at + + Values are in axis (data) units. + + limits : :class:`list` + Limits of the vertical lines + + If not given, the vertical lines will span the entire range of + the current axes. + + Values are in relative units, within a range of [0, 1]. + + properties : :class:`aspecd.plotting.LineProperties` + Properties of the line(s) within a plot + + For the properties that can be set this way, see the documentation + of the :class:`aspecd.plotting.LineProperties` class. + + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.parameters['positions'] = [] + self.parameters['limits'] = [] + self.properties = aspecd.plotting.LineProperties() + + def _perform_task(self): + for position in self.parameters['positions']: + if self.parameters['limits']: + line = self.plotter.ax.axvline( + x=position, + ymin=self.parameters['limits'][0], + ymax=self.parameters['limits'][1] + ) + else: + line = self.plotter.ax.axvline(x=position) + self.drawings.append(line) + + +class HorizontalLine(PlotAnnotation): + # noinspection PyUnresolvedReferences + """ + Horizontal line(s) added to a plot. + + Horizontal lines are often useful to compare peak positions or as a + general guide to the eye of the observer. + + The properties of the lines can be controlled in quite some detail using + the :attr:`properties` property. Note that all lines will share the same + properties. If you need to add lines with different properties to the + same plot, use several :class:`HorizontalLine` objects and annotate + separately. + + Attributes + ---------- + parameters : :class:`dict` + All parameters necessary for the annotation, implicit and explicit + + The following keys exist: + + positions : :class:`list` + List of the positions horizontal lines should appear at + + Values are in axis (data) units. + + limits : :class:`list` + Limits of the horizontal lines + + If not given, the horizontal lines will span the entire range of + the current axes. + + Values are in relative units, within a range of [0, 1]. + + properties : :class:`aspecd.plotting.LineProperties` + Properties of the line(s) within a plot + + For the properties that can be set this way, see the documentation + of the :class:`aspecd.plotting.LineProperties` class. + + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.parameters['positions'] = [] + self.parameters['limits'] = [] + self.properties = aspecd.plotting.LineProperties() def _perform_task(self): - line = self.plotter.ax.axvline(x=self.parameters['positions'][0]) - self.drawings.append(line) + for position in self.parameters['positions']: + if self.parameters['limits']: + line = self.plotter.ax.axhline( + y=position, + xmin=self.parameters['limits'][0], + xmax=self.parameters['limits'][1] + ) + else: + line = self.plotter.ax.axhline(y=position) + self.drawings.append(line) diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 6275fe6..28009e3 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -241,6 +241,22 @@ def test_annotate_adds_line_to_plotter(self): self.assertIn(annotation.drawings[0], self.plotter.ax.get_children()) + def test_annotate_adds_line_at_correct_position(self): + self.annotation.parameters['positions'] = [.5] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertEqual(annotation.parameters['positions'][0], + annotation.drawings[0].get_xdata()[0]) + + def test_annotate_adds_lines_to_plotter(self): + self.annotation.parameters['positions'] = [.25, 0.5, 0.75] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertEqual(len(annotation.parameters['positions']), + len(annotation.drawings)) + for drawing in annotation.drawings: + self.assertIn(drawing, self.plotter.ax.get_children()) + def test_annotate_adds_line_to_plotter_after_plotting(self): self.annotation.parameters['positions'] = [.5] annotation = self.plotter.annotate(self.annotation) @@ -253,3 +269,108 @@ def test_annotate_before_plotting_does_not_add_annotation_twice(self): self.plotter.annotate(self.annotation) self.plotter.plot() self.assertEqual(1, len(self.plotter.annotations)) + + def test_set_line_colour_from_dict(self): + line_colour = '#cccccc' + properties = {'color': line_colour} + self.annotation.properties.from_dict(properties) + self.assertEqual(line_colour, self.annotation.properties.color) + + def test_annotate_sets_correct_line_color(self): + color = '#cccccc' + properties = {'color': color} + self.annotation.properties.from_dict(properties) + self.plotter.plot() + self.annotation.parameters['positions'] = [.5] + annotation = self.plotter.annotate(self.annotation) + self.assertEqual(color, annotation.drawings[0].get_color()) + + def test_annotate_sets_correct_line_color_for_each_line(self): + color = '#cccccc' + properties = {'color': color} + self.annotation.properties.from_dict(properties) + self.plotter.plot() + self.annotation.parameters['positions'] = [.25, .5, .75] + annotation = self.plotter.annotate(self.annotation) + for drawing in annotation.drawings: + self.assertEqual(color, drawing.get_color()) + + def test_annotate_with_limits_sets_limits(self): + self.annotation.parameters['positions'] = [.5] + self.annotation.parameters['limits'] = [.25, .75] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertListEqual(annotation.parameters['limits'], + annotation.drawings[0].get_ydata()) + + +class TestHorizontalLine(unittest.TestCase): + def setUp(self): + self.annotation = aspecd.annotation.HorizontalLine() + self.plotter = aspecd.plotting.Plotter() + + def test_instantiate_class(self): + pass + + def test_annotate_adds_line_to_plotter(self): + self.annotation.parameters['positions'] = [.5] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertIn(annotation.drawings[0], + self.plotter.ax.get_children()) + + def test_annotate_adds_line_at_correct_position(self): + self.annotation.parameters['positions'] = [.5] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertEqual(annotation.parameters['positions'][0], + annotation.drawings[0].get_ydata()[0]) + + def test_annotate_adds_lines_to_plotter(self): + self.annotation.parameters['positions'] = [.25, 0.5, 0.75] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertEqual(len(annotation.parameters['positions']), + len(annotation.drawings)) + for drawing in annotation.drawings: + self.assertIn(drawing, self.plotter.ax.get_children()) + + def test_annotate_adds_line_to_plotter_after_plotting(self): + self.annotation.parameters['positions'] = [.5] + annotation = self.plotter.annotate(self.annotation) + self.plotter.plot() + self.assertIn(annotation.drawings[0], + self.plotter.ax.get_children()) + + def test_set_line_colour_from_dict(self): + line_colour = '#cccccc' + properties = {'color': line_colour} + self.annotation.properties.from_dict(properties) + self.assertEqual(line_colour, self.annotation.properties.color) + + def test_annotate_sets_correct_line_color(self): + color = '#cccccc' + properties = {'color': color} + self.annotation.properties.from_dict(properties) + self.plotter.plot() + self.annotation.parameters['positions'] = [.5] + annotation = self.plotter.annotate(self.annotation) + self.assertEqual(color, annotation.drawings[0].get_color()) + + def test_annotate_sets_correct_line_color_for_each_line(self): + color = '#cccccc' + properties = {'color': color} + self.annotation.properties.from_dict(properties) + self.plotter.plot() + self.annotation.parameters['positions'] = [.25, .5, .75] + annotation = self.plotter.annotate(self.annotation) + for drawing in annotation.drawings: + self.assertEqual(color, drawing.get_color()) + + def test_annotate_with_limits_sets_limits(self): + self.annotation.parameters['positions'] = [.5] + self.annotation.parameters['limits'] = [.25, .75] + self.plotter.plot() + annotation = self.plotter.annotate(self.annotation) + self.assertListEqual(annotation.parameters['limits'], + annotation.drawings[0].get_xdata()) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index c14270d..7b954ae 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -16,7 +16,7 @@ from unittest.mock import MagicMock, patch import aspecd.exceptions -from aspecd import plotting, utils, dataset, annotation, history +from aspecd import plotting, utils, dataset, annotation class TestPlotter(unittest.TestCase): From 0a7bd5d5b3a4d80173ea09cec60f8fee3c8fd639 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 16:13:17 +0200 Subject: [PATCH 39/55] Plotannotation task in recipe-driven data analysis --- VERSION | 2 +- aspecd/annotation.py | 4 +- aspecd/tasks.py | 179 ++++++++++++++++++++++++++++++++++++++++++- tests/test_tasks.py | 115 ++++++++++++++++++++++++++- 4 files changed, 296 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index bde33b5..0e53ef2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev64 +0.9.0.dev65 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index df9e117..dc928f2 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -52,7 +52,9 @@ one or multiple datasets, can be annotated as well. Plot annotations will always result in a graphical object of some kind added to the plot created by a :class:`aspecd.plotting.Plotter`. Additionally, each plotter has a list -of annotations attached to it. +of annotations attached to it. As such, plot annotations are independent of +individual datasets and can span multiple datasets in case of plotters +involving the data of multiple datasets. While generally, it should not matter whether a plot annotation gets added to the plotter object before or after the actual plotting process, adding diff --git a/aspecd/tasks.py b/aspecd/tasks.py index a9cc7cc..40d366e 100644 --- a/aspecd/tasks.py +++ b/aspecd/tasks.py @@ -692,6 +692,8 @@ class performing the respective task. * :class:`aspecd.tasks.MultiplotTask` * :class:`aspecd.tasks.CompositeplotTask` + * :class:`aspecd.tasks.PlotannotationTask` + * :class:`aspecd.tasks.TabulateTask` * :class:`aspecd.tasks.ReportTask` @@ -1146,6 +1148,7 @@ def __init__(self): self.results = collections.OrderedDict() self.figures = collections.OrderedDict() self.plotters = collections.OrderedDict() + self.plotannotations = {} self.tasks = [] self.format = { 'type': 'ASpecD recipe', @@ -2870,12 +2873,32 @@ class PlotTask(Task): key and a :obj:`aspecd.tasks.FigureRecord` object stored containing all information necessary for further handling the results of the plot. + .. note:: + + For those being confused what the differences are between + :attr:`PlotTask.label` and :attr:`PlotTask.result`: The label + refers to the *figure*, *i.e.* the result of a plotting task, + whereas the result is a reference to the actual *plotter* that has + been used to create the figure referenced with the *label*. + result : :class:`str` Label for the plotter of a plotting step. This is useful in case of CompositePlotters, where different plotters need to be defined for each of the panels. + Internally, this label is used as key in the recipe's + :attr:`Recipe.plotters` dict to store the actual + :class:`aspecd.plotting.Plotter` object. + + .. note:: + + For those being confused what the differences are between + :attr:`PlotTask.label` and :attr:`PlotTask.result`: The label + refers to the *figure*, *i.e.* the result of a plotting task, + whereas the result is a reference to the actual *plotter* that has + been used to create the figure referenced with the *label*. + target : :class:`str` Label of an existing previous plotter the plot should be added to. @@ -2889,10 +2912,19 @@ class PlotTask(Task): The result: Your plot will be a new figure window, but with the original plot contained and the new plot added on top of it. + annotations : :class:`list` + Labels of plot annotations that should be applied to the plot. + + The labels need to be valid keys of the :attr:`Recipe.annotations` + attribute. + .. versionchanged:: 0.4 Added attribute :attr:`target` + .. versionchanged:: 0.9 + Added attribute :attr:`annotations` + """ def __init__(self): @@ -2900,6 +2932,7 @@ def __init__(self): self.label = '' self.result = '' self.target = '' + self.annotations = [] self._module = 'plotting' # noinspection PyUnresolvedReferences @@ -2939,7 +2972,7 @@ def perform(self): For details, see the method :meth:`aspecd.tasks.Task.perform` of the base class. - Additionally to what is done in the base class, a PlotTask adds a + Additionally, to what is done in the base class, a PlotTask adds a :obj:`aspecd.tasks.FigureRecord` object to the :attr:`aspecd.tasks.Recipe.figures` property of the underlying recipe in case an :attr:`aspecd.tasks.PlotTask.label` has been set. @@ -2963,6 +2996,12 @@ def _add_figure_to_recipe(self): def _add_plotter_to_recipe(self): self.recipe.plotters[self.result] = self._task + def _get_annotations(self): + for annotation in self.annotations: + self._task.annotations.append( + self.recipe.plotannotations[annotation] + ) + def save_plot(self, plot=None): """ Save the figure of the plot created by the task. @@ -3119,6 +3158,7 @@ def _perform(self): self.properties.pop('filename') dataset = self.recipe.get_dataset(dataset_id) self._task = self.get_object() + self._get_annotations() self.set_colormap() if self.label and not self._task.label: self._task.label = self.label @@ -3223,6 +3263,7 @@ class MultiplotTask(PlotTask): def _perform(self): self._task = self.get_object() + self._get_annotations() self.set_colormap() if self.target: self._task.figure = self.recipe.plotters[self.target].figure @@ -3400,6 +3441,142 @@ def _perform(self): self.save_plot(plot=self._task) +class PlotannotationTask(Task): + """ + Plot annotation step defined as task in recipe-driven data analysis. + + For more information on the underlying general class, + see :class:`aspecd.annotation.PlotAnnotation`. + + Attributes + ---------- + plotter : :class:`str` + Name of the plotter to add the annotation to + + Needs to be a valid key of a plotter stored in the + :attr:`Recipe.plotters` attribute. + + result : :class:`str` + Label the plot annotation should be stored with in the recipe + + Used as key in the :attr:`Recipe.plotannotations` attribute to store + the actual :obj:`aspecd.annotation.PlotAnnotation` object. From + here, it can later be used in plot tasks to annotate the plots. + + Examples + -------- + For examples of how such a report task may be included into a recipe, + see below: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + result: plot1Dstacked + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + properties: + color: green + linewidth: 1 + linestyle: dotted + plotter: plot1Dstacked + + + In this case, the plotter is defined first, and the annotation second. + To refer to the plotter from within the plotannotation task, you need to + set the ``result`` attribute in the plotting task and refer to it within + the ``plotter`` attribute of the plotannotation task. Although defining + the plotter before the annotation, the user still expects the annotation + to be included in the file containing the actual plot, despite the fact + that the figure has been saved (for the first time) before the + annotation has been added. + + Sometimes, it might be convenient to go the other way round and first + define an annotation and afterwards add it to a plot(ter). This can be + done as well: + + .. code-block:: yaml + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: + - 21 + - 42 + properties: + color: green + linewidth: 1 + linestyle: dotted + result: vlines + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + annotations: + - vlines + + + In this way, you can add the same annotation to several plots, + and be sure that each annotation is handled as a separate object. + + Suppose you have more than one plotter you want to apply an annotation + to. In this case, the ``plotter`` property of the plotannotation task is + a list rather than a string: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot1 + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot2 + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + plotter: + - plot1 + - plot2 + + In this case, the annotation will be applied to both plots + independently. Note that the example has been reduced to the key + aspects. In a real situation, the two plotters will differ much more. + + .. versionadded:: 0.9 + + """ + + def __init__(self): + super().__init__() + self.plotter = '' + self.result = '' + self._module = 'annotation' + + def _perform(self): + task = self.get_object() + if self.plotter: + if not isinstance(self.plotter, list): + self.plotter = [self.plotter] + for plotter in self.plotter: + task.plotter = self.recipe.plotters[plotter] + # noinspection PyUnresolvedReferences + task.annotate() + elif self.result: + self.recipe.plotannotations[self.result] = task + + class ReportTask(Task): """ Reporting step defined as task in recipe-driven data analysis. diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 5b9b3fb..5d8f9d9 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -17,7 +17,8 @@ import aspecd.exceptions import aspecd.io -from aspecd import dataset, plotting, processing, report, tasks, utils +from aspecd import dataset, plotting, processing, report, tasks, utils, \ + annotation class TestRecipe(unittest.TestCase): @@ -2795,6 +2796,118 @@ def test_task_to_dict_replaces_plotter_with_label(self): dict_['properties']['plotter'][0]) +class TestPlotAnnotationTask(unittest.TestCase): + def setUp(self): + self.task = tasks.PlotannotationTask() + self.recipe = tasks.Recipe() + self.dataset = ['foo'] + self.plotter_name = 'plot' + self.plotting_task = {'kind': 'singleplot', + 'type': 'SinglePlotter', + 'result': self.plotter_name, + 'apply_to': self.dataset} + self.annotation_task = {'kind': 'plotannotation', + 'type': 'VerticalLine', + 'parameters': {'positions': [.5]}, + 'apply_to': self.dataset} + self.recipe_dict = {'settings': {'autosave_plots': False}, + 'datasets': self.dataset, + 'tasks': [self.annotation_task]} + + def prepare_recipe(self): + dataset_factory = dataset.DatasetFactory() + dataset_factory.importer_factory = aspecd.io.DatasetImporterFactory() + self.recipe.dataset_factory = dataset_factory + self.recipe.from_dict(self.recipe_dict) + + def test_instantiate_class(self): + pass + + def test_perform_task(self): + self.prepare_recipe() + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + + def test_perform_task_with_existing_plotter_annotates_plot(self): + self.recipe_dict['tasks'].append(self.plotting_task) + self.prepare_recipe() + self.annotation_task['plotter'] = self.plotter_name + plot_task = tasks.PlotTask() + plot_task.from_dict(self.plotting_task) + plot_task.recipe = self.recipe + plot_task.perform() + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + plotter = self.recipe.plotters[self.plotter_name] + self.assertTrue(plotter.annotations) + + def test_perform_task_w_multiple_plotters_annotates_plots(self): + self.prepare_recipe() + self.annotation_task['plotter'] = ['plot1', 'plot2'] + for plot in self.annotation_task['plotter']: + plot_task = tasks.PlotTask() + self.plotting_task['result'] = plot + plot_task.from_dict(self.plotting_task) + plot_task.recipe = self.recipe + plot_task.perform() + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + for plot in self.annotation_task['plotter']: + plotter = self.recipe.plotters[plot] + self.assertTrue(plotter.annotations) + + def test_perform_task_with_result_adds_plotannotation_to_recipe(self): + self.prepare_recipe() + label = 'vline' + self.annotation_task['result'] = label + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + self.assertIsInstance(self.recipe.plotannotations[label], + aspecd.annotation.PlotAnnotation) + + def test_singleplot_task_after_plotannotation_task_annotates_plot(self): + self.prepare_recipe() + label = 'vline' + self.annotation_task['result'] = label + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + + self.plotting_task['annotations'] = [label] + plot_task = tasks.SingleplotTask() + plot_task.from_dict(self.plotting_task) + plot_task.recipe = self.recipe + plot_task.perform() + + plotter = self.recipe.plotters[self.plotter_name] + self.assertTrue(plotter.annotations) + + def test_multiplot_task_after_plotannotation_task_annotates_plot(self): + self.prepare_recipe() + label = 'vline' + self.annotation_task['result'] = label + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + + plot_task = tasks.MultiplotTask() + plotting_task = {'kind': 'multiplot', + 'type': 'MultiPlotter', + 'result': self.plotter_name, + 'annotations': [label], + 'apply_to': self.dataset} + plot_task.from_dict(plotting_task) + plot_task.recipe = self.recipe + plot_task.perform() + + plotter = self.recipe.plotters[self.plotter_name] + self.assertTrue(plotter.annotations) + + class TestReportTask(unittest.TestCase): def setUp(self): self.task = tasks.ReportTask() From 0aff6e3c25faa422ed1044784aa9d98e64461054 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 16:17:48 +0200 Subject: [PATCH 40/55] Increase maximum number of lines per module --- .prospector.yaml | 2 +- VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.prospector.yaml b/.prospector.yaml index 083cf62..93f37cf 100644 --- a/.prospector.yaml +++ b/.prospector.yaml @@ -17,7 +17,7 @@ pep257: pylint: options: max-attributes: 16 - max-module-lines: 4500 + max-module-lines: 5000 pyroma: run: true diff --git a/VERSION b/VERSION index 0e53ef2..c6b9ed9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev65 +0.9.0.dev66 From 46594192feef4e22b4644917b4a1536db8af19b5 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 16:20:27 +0200 Subject: [PATCH 41/55] Update roadmap --- VERSION | 2 +- docs/roadmap.rst | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index c6b9ed9..d5282b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev66 +0.9.0.dev67 diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 0563d54..f22ab32 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -18,9 +18,11 @@ For next releases Similar to :class:`aspecd.plotting.MultiDeviceDataPlotter1D`, but with the different device data plotted in separate axes stacked vertically - * (Arbitrary) lines in plot, *e.g.* to compare peak positions + * Plot annotations - Need to decide whether this goes into plotter properties or gets handled as proper annotations; probably the former, but a good starting point to think about the latter. + * additional types of annotations, *e.g.* ``axvspan``, ``axhspan``, symbols + * ``zorder`` as attribute for annotations + * Add documentation of plot annotations to overview in plotting module * Quiver plots From 5663fb998f715dcce09f202b75b19e99d5814d4d Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 20:02:07 +0200 Subject: [PATCH 42/55] Plotannotation task seems to work --- VERSION | 2 +- aspecd/plotting.py | 3 +-- aspecd/tasks.py | 5 +++++ tests/test_tasks.py | 27 +++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index d5282b9..253734c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev67 +0.9.0.dev68 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index c9754fb..a774afd 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -833,8 +833,7 @@ def annotate(self, annotation=None): Annotation added to the plot(ter) """ - # Important: Need a copy, not the reference to the original object - annotation = copy.deepcopy(annotation) + annotation = copy.copy(annotation) annotation.annotate(self, from_plotter=True) self.annotations.append(annotation) return annotation diff --git a/aspecd/tasks.py b/aspecd/tasks.py index 40d366e..2c1f419 100644 --- a/aspecd/tasks.py +++ b/aspecd/tasks.py @@ -3563,6 +3563,7 @@ def __init__(self): self.plotter = '' self.result = '' self._module = 'annotation' + self._exclude_from_to_dict.append('apply_to') def _perform(self): task = self.get_object() @@ -3573,6 +3574,10 @@ def _perform(self): task.plotter = self.recipe.plotters[plotter] # noinspection PyUnresolvedReferences task.annotate() + if task.plotter.filename: + saver = \ + aspecd.plotting.Saver(filename=task.plotter.filename) + task.plotter.save(saver) elif self.result: self.recipe.plotannotations[self.result] = task diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 5d8f9d9..035efeb 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -2813,6 +2813,11 @@ def setUp(self): self.recipe_dict = {'settings': {'autosave_plots': False}, 'datasets': self.dataset, 'tasks': [self.annotation_task]} + self.figure_filename = 'test.pdf' + + def tearDown(self): + if os.path.exists(self.figure_filename): + os.remove(self.figure_filename) def prepare_recipe(self): dataset_factory = dataset.DatasetFactory() @@ -2843,6 +2848,21 @@ def test_perform_task_with_existing_plotter_annotates_plot(self): plotter = self.recipe.plotters[self.plotter_name] self.assertTrue(plotter.annotations) + def test_perform_task_w_existing_plotter_saves_plot_again(self): + self.recipe_dict['tasks'].append(self.plotting_task) + self.prepare_recipe() + self.annotation_task['plotter'] = self.plotter_name + plot_task = tasks.SingleplotTask() + plot_task.from_dict(self.plotting_task) + plot_task.properties['filename'] = self.figure_filename + plot_task.recipe = self.recipe + plot_task.perform() + os.remove(self.figure_filename) + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + self.assertTrue(os.path.exists(self.figure_filename)) + def test_perform_task_w_multiple_plotters_annotates_plots(self): self.prepare_recipe() self.annotation_task['plotter'] = ['plot1', 'plot2'] @@ -2907,6 +2927,13 @@ def test_multiplot_task_after_plotannotation_task_annotates_plot(self): plotter = self.recipe.plotters[self.plotter_name] self.assertTrue(plotter.annotations) + def test_apply_to_not_in_to_dict(self): + self.prepare_recipe() + self.task.from_dict(self.annotation_task) + self.task.recipe = self.recipe + self.task.perform() + self.assertNotIn('apply_to', self.task.to_dict()) + class TestReportTask(unittest.TestCase): def setUp(self): From 04c6375515edd2eaf707ba3345e20fd7f7f495d6 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Wed, 20 Sep 2023 20:31:49 +0200 Subject: [PATCH 43/55] Documentation of plot annotations in overview in plotting module; further documentation of plot annotations --- VERSION | 2 +- aspecd/annotation.py | 289 +++++++++++++++++++++++++++++++++++++++++++ aspecd/plotting.py | 102 +++++++++++++++ aspecd/tasks.py | 12 ++ docs/roadmap.rst | 1 - 5 files changed, 404 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 253734c..be43733 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev68 +0.9.0.dev69 diff --git a/aspecd/annotation.py b/aspecd/annotation.py index dc928f2..6ba2d4b 100644 --- a/aspecd/annotation.py +++ b/aspecd/annotation.py @@ -346,6 +346,97 @@ class PlotAnnotation(ToDictMixin): drawings : :class:`list` Actual graphical representations of the annotation within the plot + Examples + -------- + For examples of how such a report task may be included into a recipe, + see below: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + result: plot1Dstacked + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + properties: + color: green + linewidth: 1 + linestyle: dotted + plotter: plot1Dstacked + + + In this case, the plotter is defined first, and the annotation second. + To refer to the plotter from within the plotannotation task, you need to + set the ``result`` attribute in the plotting task and refer to it within + the ``plotter`` attribute of the plotannotation task. Although defining + the plotter before the annotation, the user still expects the annotation + to be included in the file containing the actual plot, despite the fact + that the figure has been saved (for the first time) before the + annotation has been added. + + Sometimes, it might be convenient to go the other way round and first + define an annotation and afterwards add it to a plot(ter). This can be + done as well: + + .. code-block:: yaml + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: + - 21 + - 42 + properties: + color: green + linewidth: 1 + linestyle: dotted + result: vlines + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + annotations: + - vlines + + + In this way, you can add the same annotation to several plots, + and be sure that each annotation is handled as a separate object. + + Suppose you have more than one plotter you want to apply an annotation + to. In this case, the ``plotter`` property of the plotannotation task is + a list rather than a string: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot1 + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot2 + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + plotter: + - plot1 + - plot2 + + In this case, the annotation will be applied to both plots + independently. Note that the example has been reduced to the key + aspects. In a real situation, the two plotters will differ much more. + .. versionadded:: 0.9 @@ -451,6 +542,105 @@ class VerticalLine(PlotAnnotation): of the :class:`aspecd.plotting.LineProperties` class. + Examples + -------- + For convenience, a series of examples in recipe style (for details of + the recipe-driven data analysis, see :mod:`aspecd.tasks`) is given below + for how to make use of this class. The examples focus each on a single + aspect. + + Generally and for obvious reasons, you need to have both, a plot task + and a plotannotation task. It does not really matter which task you + define first, the plot or the plot annotation. There are only marginal + differences, and both ways are shown below. + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + result: plot1Dstacked + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + properties: + color: green + linewidth: 1 + linestyle: dotted + plotter: plot1Dstacked + + + In this case, the plotter is defined first, and the annotation second. + To refer to the plotter from within the plotannotation task, you need to + set the ``result`` attribute in the plotting task and refer to it within + the ``plotter`` attribute of the plotannotation task. Although defining + the plotter before the annotation, the user still expects the annotation + to be included in the file containing the actual plot, despite the fact + that the figure has been saved (for the first time) before the + annotation has been added. + + Sometimes, it might be convenient to go the other way round and first + define an annotation and afterwards add it to a plot(ter). This can be + done as well: + + .. code-block:: yaml + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: + - 21 + - 42 + properties: + color: green + linewidth: 1 + linestyle: dotted + result: vlines + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + annotations: + - vlines + + + In this way, you can add the same annotation to several plots, + and be sure that each annotation is handled as a separate object. + + Suppose you have more than one plotter you want to apply an annotation + to. In this case, the ``plotter`` property of the plotannotation task is + a list rather than a string: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot1 + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot2 + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + plotter: + - plot1 + - plot2 + + In this case, the annotation will be applied to both plots + independently. Note that the example has been reduced to the key + aspects. In a real situation, the two plotters will differ much more. + + .. versionadded:: 0.9 """ @@ -515,6 +705,105 @@ class HorizontalLine(PlotAnnotation): of the :class:`aspecd.plotting.LineProperties` class. + Examples + -------- + For convenience, a series of examples in recipe style (for details of + the recipe-driven data analysis, see :mod:`aspecd.tasks`) is given below + for how to make use of this class. The examples focus each on a single + aspect. + + Generally and for obvious reasons, you need to have both, a plot task + and a plotannotation task. It does not really matter which task you + define first, the plot or the plot annotation. There are only marginal + differences, and both ways are shown below. + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + result: plot1Dstacked + + - kind: plotannotation + type: HorizontalLine + properties: + parameters: + positions: [35, 42] + properties: + color: green + linewidth: 1 + linestyle: dotted + plotter: plot1Dstacked + + + In this case, the plotter is defined first, and the annotation second. + To refer to the plotter from within the plotannotation task, you need to + set the ``result`` attribute in the plotting task and refer to it within + the ``plotter`` attribute of the plotannotation task. Although defining + the plotter before the annotation, the user still expects the annotation + to be included in the file containing the actual plot, despite the fact + that the figure has been saved (for the first time) before the + annotation has been added. + + Sometimes, it might be convenient to go the other way round and first + define an annotation and afterwards add it to a plot(ter). This can be + done as well: + + .. code-block:: yaml + + - kind: plotannotation + type: HorizontalLine + properties: + parameters: + positions: + - 21 + - 42 + properties: + color: green + linewidth: 1 + linestyle: dotted + result: hlines + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + annotations: + - hlines + + + In this way, you can add the same annotation to several plots, + and be sure that each annotation is handled as a separate object. + + Suppose you have more than one plotter you want to apply an annotation + to. In this case, the ``plotter`` property of the plotannotation task is + a list rather than a string: + + .. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot1 + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot2 + + - kind: plotannotation + type: HorizontalLine + properties: + parameters: + positions: [35, 42] + plotter: + - plot1 + - plot2 + + In this case, the annotation will be applied to both plots + independently. Note that the example has been reduced to the key + aspects. In a real situation, the two plotters will differ much more. + + .. versionadded:: 0.9 """ diff --git a/aspecd/plotting.py b/aspecd/plotting.py index a774afd..9e85f3a 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -486,6 +486,108 @@ properties explicitly. For details, see the :class:`LegendProperties` class. +Annotating plots +---------------- + +Annotations of plots are something that cannot be automated. However, +they can be quite important for the analysis and hence for providing new +scientific insight. Typical simple examples of plot annotations are +horizontal or vertical lines to compare peak positions or intensities. You +may as well think of highlighted areas or symbols pointing to distinct +characteristics. + +When annotating plots, for obvious reasons you need to have both, a plot task +and a plotannotation task. It does not really matter which task you define +first, the plot or the plot annotation. There are only marginal +differences, and both ways are shown below. + +.. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + result: plot1Dstacked + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + properties: + color: green + linewidth: 1 + linestyle: dotted + plotter: plot1Dstacked + + +In this case, the plotter is defined first, and the annotation second. +To refer to the plotter from within the plotannotation task, you need to +set the ``result`` attribute in the plotting task and refer to it within +the ``plotter`` attribute of the plotannotation task. Although defining +the plotter before the annotation, the user still expects the annotation +to be included in the file containing the actual plot, despite the fact +that the figure has been saved (for the first time) before the +annotation has been added. + +Sometimes, it might be convenient to go the other way round and first +define an annotation and afterwards add it to a plot(ter). This can be +done as well: + +.. code-block:: yaml + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: + - 21 + - 42 + properties: + color: green + linewidth: 1 + linestyle: dotted + result: vlines + + - kind: multiplot + type: MultiPlotter1DStacked + properties: + filename: plot1Dstacked.pdf + annotations: + - vlines + + +In this way, you can add the same annotation to several plots, +and be sure that each annotation is handled as a separate object. + +Suppose you have more than one plotter you want to apply an annotation +to. In this case, the ``plotter`` property of the plotannotation task is +a list rather than a string: + +.. code-block:: yaml + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot1 + + - kind: multiplot + type: MultiPlotter1DStacked + result: plot2 + + - kind: plotannotation + type: VerticalLine + properties: + parameters: + positions: [35, 42] + plotter: + - plot1 + - plot2 + +In this case, the annotation will be applied to both plots +independently. Note that the example has been reduced to the key +aspects. In a real situation, the two plotters will differ much more. + + Adding a colorbar ----------------- diff --git a/aspecd/tasks.py b/aspecd/tasks.py index 2c1f419..d030dc8 100644 --- a/aspecd/tasks.py +++ b/aspecd/tasks.py @@ -986,6 +986,18 @@ class Recipe: result. This is mainly used for tasks involving CompositePlotters, to define the plotters for each individual plot panel. + plotannotations : :class:`dict` + Dictionary of plot annotations originating from plotannotation tasks + + Each entry is an object of class + :class:`aspecd.annotation.PlotAnnotation`. + + To end up in the list of plotannotations, the plotannotation task + needs to define a result. Thus you can first define plot + annotations before applying them to plotters, and you can even + apply one and the same plotannotation task to multiple plotters, + referring to the plotannotation by its label. + dataset_factory : :class:`aspecd.dataset.DatasetFactory` Factory for datasets used to retrieve datasets diff --git a/docs/roadmap.rst b/docs/roadmap.rst index f22ab32..4c39c55 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -22,7 +22,6 @@ For next releases * additional types of annotations, *e.g.* ``axvspan``, ``axhspan``, symbols * ``zorder`` as attribute for annotations - * Add documentation of plot annotations to overview in plotting module * Quiver plots From 20fd06c096d9a8c40402ef06b4fe2083a16354d1 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 22 Sep 2023 12:46:26 +0200 Subject: [PATCH 44/55] specd.utils.ToDictMixin.to_dict does not traverse settings for properties to exclude and include --- VERSION | 2 +- aspecd/utils.py | 31 ++++++++++++------ docs/changelog.rst | 8 ++++- tests/test_dataset.py | 4 +++ tests/test_utils.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 11 deletions(-) diff --git a/VERSION b/VERSION index be43733..b97efa4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev69 +0.9.0.dev70 diff --git a/aspecd/utils.py b/aspecd/utils.py index 8258f7c..691cf3e 100644 --- a/aspecd/utils.py +++ b/aspecd/utils.py @@ -159,23 +159,36 @@ def to_dict(self, remove_empty=False): """ if hasattr(self, '__odict__'): - result = self._traverse_dict(self.__odict__) + result = self._traverse_dict(self._clean_dict(self.__odict__)) else: - result = self._traverse_dict(self.__dict__) + result = self._traverse_dict(self._clean_dict(self.__dict__)) if remove_empty: result = remove_empty_values_from_dict(result) return result + def _clean_dict(self, dictionary): + to_remove = [] + for key in dictionary: + if (str(key).startswith('_') + and not key in self._include_in_to_dict) \ + or str(key) in self._exclude_from_to_dict: + to_remove.append(key) + for key in to_remove: + dictionary.pop(key, None) + for key in self._include_in_to_dict: + dictionary[key] = getattr(self, key) + return dictionary + def _traverse_dict(self, instance_dict): output = collections.OrderedDict() for key, value in instance_dict.items(): - if str(key).startswith('_') \ - or str(key) in self._exclude_from_to_dict: - pass - else: - output[key] = self._traverse(key, value) - for key in self._include_in_to_dict: - output[key] = self._traverse(key, getattr(self, key)) + # if str(key).startswith('_') \ + # or str(key) in self._exclude_from_to_dict: + # pass + # else: + # output[key] = self._traverse(key, value) + # for key in self._include_in_to_dict: + output[key] = self._traverse(key, value) return output def _traverse(self, key, value): diff --git a/docs/changelog.rst b/docs/changelog.rst index 5133549..8cd0c3c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -28,7 +28,7 @@ New features * Annotations for plots - For details, see :ref:`the documentation of plot annotations <:sec:annotation:plot>`. + For details, see :ref:`the documentation of plot annotations <:sec:annotation:plot>` and the :mod:`aspecd.annotation` module. * Device data @@ -68,6 +68,12 @@ Documentation * Section with :ref:`general tips and tricks for styling plotters `. +Fixes +----- + +* :meth:`aspecd.utils.ToDictMixin.to_dict` does not traverse settings for properties to exclude and include. + + Version 0.8.3 ============= diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 39c1f33..c249319 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -661,6 +661,10 @@ def test_to_dict_includes_package_name(self): def test_to_dict_includes_history_pointer(self): self.assertIn("_history_pointer", self.dataset.to_dict()) + def test_to_dict_with_device_data(self): + self.dataset.device_data['foo'] = aspecd.dataset.DeviceData() + self.assertNotIn('_origdata', self.dataset.to_dict()['device_data']) + class TestDatasetFromDict(unittest.TestCase): def setUp(self): diff --git a/tests/test_utils.py b/tests/test_utils.py index 8d85dfb..e3bac9c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -231,6 +231,80 @@ def __init__(self): self.assertEqual(['comment'], list(obj.to_dict(remove_empty=True).keys())) + def test_properties_to_include_are_not_traversed(self): + class Test(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.purpose = {} + self._foo = None + self._include_in_to_dict = ['_foo'] + + obj = Test() + self.assertNotIn('_foo', list(obj.to_dict()['purpose'])) + + def test_properties_to_exclude_are_not_traversed(self): + class Test(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.purpose = {'operator': 'John Doe'} + self.operator = '' + self._exclude_from_to_dict = ['operator'] + + obj = Test() + self.assertIn('operator', obj.to_dict()['purpose']) + + def test_properties_to_include_are_taken_from_object(self): + class TestProperty(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.operator = 'John Doe' + self._bar = None + self._include_in_to_dict = ['_bar'] + + class Test(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.purpose = TestProperty() + self._foo = None + self._include_in_to_dict = ['_foo'] + + obj = Test() + self.assertIn('_bar', list(obj.to_dict()['purpose'])) + + def test_properties_to_exclude_are_taken_from_object(self): + class TestProperty(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.operator = 'John Doe' + self._bar = None + self._exclude_from_to_dict = ['_bar'] + + class Test(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.purpose = {'operator': 'John Doe'} + self.operator = TestProperty() + self._exclude_from_to_dict = ['operator'] + + obj = Test() + self.assertNotIn('_bar', obj.to_dict()['purpose']) + + def test_property_to_include_is_property(self): + class Test(utils.ToDictMixin): + def __init__(self): + super().__init__() + self.purpose = '' + self._prop = 'foo' + self._include_in_to_dict = ['prop'] + + @property + def prop(self): + return self._prop + + obj = Test() + self.assertEqual(['purpose', 'prop'], + list(obj.to_dict().keys())) + class TestGetAspecdVersion(unittest.TestCase): From 17720dda8554f375cbe52475357d9c281c0b27e4 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 22 Sep 2023 12:55:57 +0200 Subject: [PATCH 45/55] Update dataset structures in documentation --- VERSION | 2 +- docs/CalculatedDataset.yaml | 31 ++++++++------ docs/Dataset.yaml | 25 ++++++----- docs/ExperimentalDataset.yaml | 80 ++++++++++++++++++++++++----------- docs/dataset-structure.rst | 2 + docs/datasets2yaml.py | 3 ++ 6 files changed, 95 insertions(+), 48 deletions(-) diff --git a/VERSION b/VERSION index b97efa4..6087bec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev70 +0.9.0.dev71 diff --git a/docs/CalculatedDataset.yaml b/docs/CalculatedDataset.yaml index 45fe3f0..eb81e5e 100644 --- a/docs/CalculatedDataset.yaml +++ b/docs/CalculatedDataset.yaml @@ -13,6 +13,7 @@ data: type: numpy.ndarray dtype: float64 array: [] + index: [] - quantity: '' symbol: '' unit: '' @@ -21,18 +22,7 @@ data: type: numpy.ndarray dtype: float64 array: [] -metadata: - calculation: - type: '' - parameters: {} -history: [] -analyses: [] -annotations: [] -representations: [] -id: '' -label: '' -references: [] -tasks: [] + index: [] _origdata: calculated: true data: @@ -48,6 +38,7 @@ _origdata: type: numpy.ndarray dtype: float64 array: [] + index: [] - quantity: '' symbol: '' unit: '' @@ -56,5 +47,19 @@ _origdata: type: numpy.ndarray dtype: float64 array: [] -_package_name: aspecd + index: [] +device_data: {} +metadata: + calculation: + type: '' + parameters: {} +history: [] _history_pointer: -1 +analyses: [] +annotations: [] +representations: [] +id: '' +label: '' +references: [] +tasks: [] +_package_name: aspecd diff --git a/docs/Dataset.yaml b/docs/Dataset.yaml index db7029b..5373730 100644 --- a/docs/Dataset.yaml +++ b/docs/Dataset.yaml @@ -13,6 +13,7 @@ data: type: numpy.ndarray dtype: float64 array: [] + index: [] - quantity: '' symbol: '' unit: '' @@ -21,15 +22,7 @@ data: type: numpy.ndarray dtype: float64 array: [] -metadata: {} -history: [] -analyses: [] -annotations: [] -representations: [] -id: '' -label: '' -references: [] -tasks: [] + index: [] _origdata: calculated: false data: @@ -45,6 +38,7 @@ _origdata: type: numpy.ndarray dtype: float64 array: [] + index: [] - quantity: '' symbol: '' unit: '' @@ -53,5 +47,16 @@ _origdata: type: numpy.ndarray dtype: float64 array: [] -_package_name: aspecd + index: [] +device_data: {} +metadata: {} +history: [] _history_pointer: -1 +analyses: [] +annotations: [] +representations: [] +id: '' +label: '' +references: [] +tasks: [] +_package_name: aspecd diff --git a/docs/ExperimentalDataset.yaml b/docs/ExperimentalDataset.yaml index 69367ea..517e52a 100644 --- a/docs/ExperimentalDataset.yaml +++ b/docs/ExperimentalDataset.yaml @@ -13,6 +13,7 @@ data: type: numpy.ndarray dtype: float64 array: [] + index: [] - quantity: '' symbol: '' unit: '' @@ -21,6 +22,60 @@ data: type: numpy.ndarray dtype: float64 array: [] + index: [] +_origdata: + calculated: false + data: + type: numpy.ndarray + dtype: float64 + array: [] + axes: + - quantity: '' + symbol: '' + unit: '' + label: '' + values: + type: numpy.ndarray + dtype: float64 + array: [] + index: [] + - quantity: '' + symbol: '' + unit: '' + label: '' + values: + type: numpy.ndarray + dtype: float64 + array: [] + index: [] +device_data: + example: + calculated: false + metadata: + label: '' + data: + type: numpy.ndarray + dtype: float64 + array: [] + axes: + - quantity: '' + symbol: '' + unit: '' + label: '' + values: + type: numpy.ndarray + dtype: float64 + array: [] + index: [] + - quantity: '' + symbol: '' + unit: '' + label: '' + values: + type: numpy.ndarray + dtype: float64 + array: [] + index: [] metadata: measurement: start: null @@ -40,6 +95,7 @@ metadata: value: 0.0 controller: '' history: [] +_history_pointer: -1 analyses: [] annotations: [] representations: [] @@ -47,28 +103,4 @@ id: '' label: '' references: [] tasks: [] -_origdata: - calculated: false - data: - type: numpy.ndarray - dtype: float64 - array: [] - axes: - - quantity: '' - symbol: '' - unit: '' - label: '' - values: - type: numpy.ndarray - dtype: float64 - array: [] - - quantity: '' - symbol: '' - unit: '' - label: '' - values: - type: numpy.ndarray - dtype: float64 - array: [] _package_name: aspecd -_history_pointer: -1 diff --git a/docs/dataset-structure.rst b/docs/dataset-structure.rst index 65d21a0..3fa1207 100644 --- a/docs/dataset-structure.rst +++ b/docs/dataset-structure.rst @@ -19,6 +19,8 @@ Basic dataset Experimental dataset ==================== +While generally, the propery ``device_data`` is empty when creating a dataset object, here, the structure of the ``device_data`` is shown explicitly for one device named ``example``. For more details regarding device data, see the :ref:`documentation in the dataset module `. + .. literalinclude:: ExperimentalDataset.yaml :language: yaml diff --git a/docs/datasets2yaml.py b/docs/datasets2yaml.py index 465d73b..e680c78 100644 --- a/docs/datasets2yaml.py +++ b/docs/datasets2yaml.py @@ -1,3 +1,4 @@ +import aspecd.dataset import aspecd.utils @@ -7,6 +8,8 @@ yaml = aspecd.utils.Yaml() ds = aspecd.utils.object_from_class_name(".".join(['aspecd.dataset', class_name])) + if class_name == 'ExperimentalDataset': + ds.device_data['example'] = aspecd.dataset.DeviceData() yaml.dict = ds.to_dict() yaml.serialise_numpy_arrays() yaml.write_to(".".join([class_name, 'yaml'])) From 47e576b68589aff312cae7d399bcceac2980bb9d Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 22 Sep 2023 14:04:46 +0200 Subject: [PATCH 46/55] Code cleanup after prospector run --- .prospector.yaml | 2 ++ VERSION | 2 +- aspecd/utils.py | 11 ++++------- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.prospector.yaml b/.prospector.yaml index 93f37cf..5c61ca8 100644 --- a/.prospector.yaml +++ b/.prospector.yaml @@ -1,6 +1,8 @@ # For general information of how to configure prospector visit # https://prospector.landscape.io/en/master/profiles.html +autodetect: false + inherits: - strictness_high diff --git a/VERSION b/VERSION index 6087bec..255c442 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev71 +0.9.0.dev72 diff --git a/aspecd/utils.py b/aspecd/utils.py index 691cf3e..b80019e 100644 --- a/aspecd/utils.py +++ b/aspecd/utils.py @@ -157,6 +157,9 @@ def to_dict(self, remove_empty=False): .. versionchanged:: 0.6 New parameter `remove_empty` + .. versionchanged:: 0.9 + Settings for properties to exclude and include are not traversed + """ if hasattr(self, '__odict__'): result = self._traverse_dict(self._clean_dict(self.__odict__)) @@ -170,7 +173,7 @@ def _clean_dict(self, dictionary): to_remove = [] for key in dictionary: if (str(key).startswith('_') - and not key in self._include_in_to_dict) \ + and key not in self._include_in_to_dict) \ or str(key) in self._exclude_from_to_dict: to_remove.append(key) for key in to_remove: @@ -182,12 +185,6 @@ def _clean_dict(self, dictionary): def _traverse_dict(self, instance_dict): output = collections.OrderedDict() for key, value in instance_dict.items(): - # if str(key).startswith('_') \ - # or str(key) in self._exclude_from_to_dict: - # pass - # else: - # output[key] = self._traverse(key, value) - # for key in self._include_in_to_dict: output[key] = self._traverse(key, value) return output From 439e034fbf77ad606976d258ea917a949a2e57ac Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 22 Dec 2023 14:07:47 +0100 Subject: [PATCH 47/55] Primitive report tests now contain assert statement --- VERSION | 2 +- tests/test_report.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/VERSION b/VERSION index 255c442..ea66b67 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev72 +0.9.0.dev73 diff --git a/tests/test_report.py b/tests/test_report.py index 41de970..9b17f67 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -83,9 +83,10 @@ def test_render_with_nonexistent_template_raises(self): def test_render_with_template(self): with open(self.template, 'w+') as f: - f.write('') + f.write('Lorem ipsum') self.report.template = self.template self.report.render() + self.assertTrue(self.report.report) def test_render_adds_sysinfo_key_to_context(self): with open(self.template, 'w+') as f: @@ -103,9 +104,10 @@ def test_context_has_sysinfo_packages_key(self): def test_render_with_template_with_absolute_path(self): with open(self.template2, 'w+') as f: - f.write('') + f.write('Lorem ipsum') self.report.template = self.template2 self.report.render() + self.assertTrue(self.report.report) def test_render_sets_template_dir_in_context(self): with open(self.template2, 'w+') as f: @@ -133,15 +135,17 @@ def test_render_sets_timestamp_in_context(self): def test_render_with_template_with_relative_path(self): with open(self.template, 'w+') as f: - f.write('') + f.write('Lorem ipsum') self.report.template = '../tests/' + self.template self.report.render() + self.assertTrue(self.report.report) def test_render_with_template_provided_at_initialisation(self): with open(self.template, 'w+') as f: - f.write('') + f.write('Lorem ipsum') report_ = report.Reporter(template=self.template) report_.render() + self.assertTrue(report_.report) def test_render_fills_report_property(self): content = 'bla' From eb7bf6249bb9cca9ada1752f43481d00a3bb2837 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Fri, 22 Dec 2023 14:27:37 +0100 Subject: [PATCH 48/55] Setting font size of axes labels. --- VERSION | 2 +- aspecd/plotting.py | 24 +++++++++++++++++++++++- docs/changelog.rst | 2 ++ tests/test_plotting.py | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index ea66b67..d7d33b6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev73 +0.9.0.dev74 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 9e85f3a..15d8897 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -4206,6 +4206,16 @@ class AxesProperties(aspecd.utils.Properties): Default: None + label_fontsize : :class:`int` or :class:`str` + Font size of the axes labels. + + If numeric the size will be the absolute font size in points. String + values are relative to the current default font size. Valid string + values are: ``xx-small``, ``x-small``, ``small``, ``medium``, + ``large``, ``x-large``, ``xx-large`` + + Default: ``plt.rcParams['font.size']`` + invert: :class:`list` or :class:`str` Axes to invert @@ -4235,6 +4245,9 @@ class AxesProperties(aspecd.utils.Properties): .. versionchanged:: 0.9 New property ``invert`` + .. versionchanged:: 0.9 + New property ``label_fontsize`` + """ # pylint: disable=too-many-instance-attributes @@ -4256,6 +4269,7 @@ def __init__(self): self.yticklabels = None self.yticklabelangle = 0.0 self.yticks = None + self.label_fontsize = plt.rcParams['font.size'] self.invert = None def apply(self, axes=None): @@ -4284,6 +4298,7 @@ def apply(self, axes=None): if hasattr(axes, 'set_' + property_): getattr(axes, 'set_' + property_)(value) self._set_axes_ticks(axes) + self._set_axes_fonts(axes) if self.invert: self._invert_axes(axes) @@ -4305,7 +4320,10 @@ def _get_settable_properties(self): all_properties = self.to_dict() properties = {} for prop in all_properties: - if prop.startswith(('xtick', 'ytick', 'invert')): + if ( + prop.startswith(('xtick', 'ytick', 'invert')) + or "fontsize" in prop + ): pass elif isinstance(all_properties[prop], np.ndarray): if any(all_properties[prop]): @@ -4328,6 +4346,10 @@ def _set_axes_ticks(self, axes): for tick in axes.get_yticklabels(): tick.set_rotation(self.yticklabelangle) + def _set_axes_fonts(self, axes): + axes.get_xaxis().get_label().set_fontsize(self.label_fontsize) + axes.get_yaxis().get_label().set_fontsize(self.label_fontsize) + def _invert_axes(self, axes): if isinstance(self.invert, str): self.invert = [self.invert] diff --git a/docs/changelog.rst b/docs/changelog.rst index 8cd0c3c..211a5dc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -24,6 +24,8 @@ New features * New attribute :attr:`aspecd.plotting.AxesProperties.invert` for inverting axes. Helpful, *e.g.*, for plotting FTIR data without having to resort to explicitly provide descending axis limits. + * Setting font size of axes labels via ``label_fontsize`` property. + * Colorbar for 2D plotter * Annotations for plots diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 7b954ae..7dcc9f3 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -2648,6 +2648,22 @@ def test_invert_does_not_invert_already_inverted_y_axis(self): self.assertTrue(plot.axes.yaxis_inverted()) plt.close(plot.figure) + def test_label_fontsize_sets_label_fontsize(self): + fontsize = 'large' + self.axis_properties.label_fontsize = fontsize + plot = plotting.Plotter() + plot.plot() + self.axis_properties.apply(axes=plot.axes) + self.assertEqual( + plt.rcParams['font.size'] * 1.2, + plot.axes.get_xaxis().get_label().get_fontsize() + ) + self.assertEqual( + plt.rcParams['font.size'] * 1.2, + plot.axes.get_yaxis().get_label().get_fontsize() + ) + plt.close(plot.figure) + class TestLegendProperties(unittest.TestCase): def setUp(self): From 13797a23a98ad544ce632c0ebd4c7d2ba7f3bd0f Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 7 Jan 2024 18:33:42 +0100 Subject: [PATCH 49/55] Update dates; fix recipe in use cases: plotter properties --- LICENSE | 2 +- VERSION | 2 +- docs/conf.py | 2 +- docs/usecases.rst | 25 +++++++++++++------------ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/LICENSE b/LICENSE index 7ec8eef..1b2fa4c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017-22 Till Biskup +Copyright (c) 2017-24 Till Biskup All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/VERSION b/VERSION index d7d33b6..caac313 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev74 +0.9.0.dev75 diff --git a/docs/conf.py b/docs/conf.py index badcf7b..cb1dbdc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ release_ = version_file.read().strip() project = 'ASpecD' -copyright = '2018–23, Till Biskup' +copyright = '2018–24, Till Biskup' author = 'Till Biskup' # The short X.Y version diff --git a/docs/usecases.rst b/docs/usecases.rst index 871f87a..9a00644 100644 --- a/docs/usecases.rst +++ b/docs/usecases.rst @@ -411,18 +411,19 @@ To give you a first impression of how a more detailed and explicit setting of pl - kind: singleplot type: SinglePlotter1D properties: - figure: - size: 6, 4.5 - dpi: 300 - title: My first figure - axes: - facecolor: '#cccccc' - drawing: - color: tab:red - linewidth: 2 - legend: - location: upper right - frameon: False + properties: + figure: + size: 6, 4.5 + dpi: 300 + title: My first figure + axes: + facecolor: '#cccccc' + drawing: + color: tab:red + linewidth: 2 + legend: + location: upper right + frameon: False filename: - dataset.pdf From 3022a13e78edf32d8bc91499cf337cfda8b0ac8d Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 7 Jan 2024 19:30:46 +0100 Subject: [PATCH 50/55] Workaround for matplotlib bug not correctly handing figure dpi settings. --- VERSION | 2 +- aspecd/plotting.py | 4 +++- docs/changelog.rst | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index caac313..a03159e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev75 +0.9.0.dev76 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 15d8897..c3ecb21 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -3465,7 +3465,9 @@ def _save_plot(self): """ self._add_file_extension() try: - self.plotter.figure.savefig(self.filename, **self.parameters) + self.plotter.figure.savefig(self.filename, + dpi=self.plotter.figure.dpi, + **self.parameters) except OSError as os_error: if os_error.errno == errno.ENAMETOOLONG: file_basename, file_extension = os.path.splitext(self.filename) diff --git a/docs/changelog.rst b/docs/changelog.rst index 211a5dc..725fba4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -74,6 +74,7 @@ Fixes ----- * :meth:`aspecd.utils.ToDictMixin.to_dict` does not traverse settings for properties to exclude and include. +* Workaround for :meth:`matplotlib.figure.Figure.savefig` not correctly handling figure DPI settings. Version 0.8.3 From 3bf2ce85dcc994c1e1c1dfc8135f6fb2fe6e9a29 Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 7 Jan 2024 21:42:24 +0100 Subject: [PATCH 51/55] Start developer documentation for plotters. With ASpecD v0.9, support for device data gets added, and this requires some changes in how plotters access the actual data to be plotted. Useful information for developers what needs to change was missing. --- VERSION | 2 +- aspecd/plotting.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ docs/changelog.rst | 2 +- docs/roadmap.rst | 2 ++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index a03159e..3573dab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev76 +0.9.0.dev77 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index c3ecb21..068546e 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -681,6 +681,61 @@ works. +For developers +============== + +A bit of conceptual documentation for both, developers of the ASpecD +framework and derived packages, including general hints how to implement +plotters. + + +.. _sec:plotting:developers_data: + +Access to data for plotting +--------------------------- + +Datasets may contain additional data as device data in +:attr:`aspecd.dataset.Dataset.device_data`. For details, see the +:ref:`section on device data in the dataset module +`. When implementing a plotter, you should not +need to care about whether device data or data should be plotted. For this +to work, do *not* access :attr:`aspecd.dataset.Dataset.data` directly +in your plotter, but use instead :attr:`aspecd.plotting.SinglePlotter.data` +or :attr:`aspecd.plotting.MultiPlotter.data`, respectively. + + +.. important:: + Support for device data has been added in ASpecD v0.9. Developers of + packages based on the ASpecD framework should update their code + accordingly. + + +In a simplistic scenario, your plotter (here, a class derived from +:class:`SinglePlotter`) may contain the following code: + +.. code-block:: + + def _create_plot(self): + self.drawing, = self.axes.plot(self.data.axes[0].values, + self.data.data, + label=self.properties.drawing.label) + + +A few comments on this code snippet: + +* All actual plotting is implemented in the private method + ``_create_plot()``. + +* The actual object returned by the plot function is stored in + ``self.drawing``. + +* The actual plot function gets the data to be plotted by accessing + ``self.data`` (and *not* ``self.dataset.data``). + +Of course, usually there is more that is handled in a plotter. For +details, have a look at the actual source code of different ASpecD plotters. + + Module API documentation ======================== @@ -1206,6 +1261,10 @@ class SinglePlotter(Plotter): aspecd.exceptions.NotApplicableToDatasetError Raised when processing step is not applicable to dataset + + .. versionchanged:: 0.9 + New attribute ''data'' + """ def __init__(self): @@ -2506,6 +2565,15 @@ class MultiPlotter(Plotter): datasets : :class:`list` List of dataset the plotting should be done for + data : :class:`list` + List of actual data that should be plotted + + Each element is of type :class:`aspecd.dataset.Data`. + + Defaults to the primary data of a dataset, but can be the device + data. See the key ``device_data`` of :attr:`Plotter.parameters` for + details. + parameters : :class:`dict` All parameters necessary for the plot, implicit and explicit @@ -2529,6 +2597,10 @@ class MultiPlotter(Plotter): aspecd.exceptions.NotApplicableToDatasetError Raised when processing step is not applicable to dataset + + .. versionchanged:: 0.9 + New attribute ''data'' + """ def __init__(self): diff --git a/docs/changelog.rst b/docs/changelog.rst index 725fba4..a739249 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -50,7 +50,7 @@ New features Changes ------- -* Plotters can now handle device data instead of the primary data of a dataset (see above). This means, however, that instead of accessing ``self.dataset.data`` (or ``self.datasets[#].data``), plotters need to access ``self.data.data`` (or ``self.data[#].data``) instead. **Authors of derived packages should update their plotters accordingly.** +* Plotters can now handle device data instead of the primary data of a dataset (see above). This means, however, that instead of accessing ``self.dataset.data`` (or ``self.datasets[#].data``), plotters need to access ``self.data.data`` (or ``self.data[#].data``) instead. **Authors of derived packages should update their plotters accordingly.** See the :ref:`hints for developers on device data in the plotting module ` * Serving recipes logs messages from all ASpecD modules, not only from the :mod:`aspecd.tasks` module. diff --git a/docs/roadmap.rst b/docs/roadmap.rst index 4c39c55..c8b94d4 100644 --- a/docs/roadmap.rst +++ b/docs/roadmap.rst @@ -62,6 +62,8 @@ For next releases * Documentation: + * More developer documentation providing hints and "best practices" for how to develop classes either in ASpecD or in derived packages. + * How to debug a recipe? * Better document command-line options of the "serve" command From ceeb726fdcd5d297a7845c59312e177ac9807b0e Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 7 Jan 2024 21:49:14 +0100 Subject: [PATCH 52/55] Fix grammar in FTIR example in documentation. --- VERSION | 2 +- docs/examples/ftir.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 3573dab..7eec193 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev77 +0.9.0.dev78 diff --git a/docs/examples/ftir.rst b/docs/examples/ftir.rst index f9cacee..8617939 100644 --- a/docs/examples/ftir.rst +++ b/docs/examples/ftir.rst @@ -16,7 +16,7 @@ To this end, a series of tasks needs to be performed on each dataset: #. Plot both spectra in one axis for graphical display of recorded data, following the convention in FTIR to plot an inverse *x* axis. -There are two ways to invert an axis: The preferred method is to explicitly set the axis property (note that you can specify which axis to invert or even both, if you provide a list). Alternatively, shown here as a comment, is to provide axis limits in descending order. While the latter method does do the trick, you need to explicitly provide axis limits in this case. This might, however, not be convenient. +There are two ways to invert an axis: The preferred method is to explicitly set the axis property (note that you can specify which axis to invert or even both, if you provide a list). Alternatively, shown here as a comment, you can provide axis limits in descending order. While the latter method does do the trick, you need to explicitly provide axis limits in this case. This might, however, not be convenient. In case of the data used here, the *x* axis is recorded in descending order. Therefore, for the baseline correction step, the five percent fitting range are taken from the left part in the figure, *i.e.* at high wavenumbers. Depending on how your data were recorded and how you set your plot initially, this may be confusing and lead to unexpected results. From ef36f346c66ea685eee2d3fd5da666598d66c11e Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sun, 7 Jan 2024 23:45:41 +0100 Subject: [PATCH 53/55] Plotter documentation: When and how to subclass plotter? --- VERSION | 2 +- aspecd/plotting.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7eec193..c672a41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev78 +0.9.0.dev79 diff --git a/aspecd/plotting.py b/aspecd/plotting.py index 068546e..a9ea51f 100644 --- a/aspecd/plotting.py +++ b/aspecd/plotting.py @@ -689,6 +689,55 @@ plotters. +When and how to subclass plotters +--------------------------------- + +ASpecD comes with a list and hierarchy of plotters. For details, see the +:ref:`section on types of concrete plotters +`. The question therefore arises: when and +how to subclass plotters, particularly in derived packages? + +Generally, you nearly always want to subclass directly one of the concrete +plotters, such as :class:`SinglePlotter1D` or :class:`MultiPlotter1D`, +but rarely if ever parent classes such as :class:`SinglePlotter` or even +:class:`Plotter`. The reason is simply that only the concrete plotters can +be used directly. + +Reasons for subclassing plotters in derived packages are: + +* Adding new kinds of (specific) plotters, +* Adding functionality to otherwise generic plotters, +* Change certain functionality to otherwise generic plotters. + +A typical use case for the last case would be to revert the *x* axis by +default, perhaps depending on the axis unit. For this, you would +probably want to subclass all relevant concrete ASpecD plotter, *i.e.* +:class:`SinglePlotter1D`, :class:`SinglePlotter2D`, +:class:`SinglePlotter2DStacked`, :class:`MultiPlotter1D`, +and :class:`MultiPlotter1DStacked`. For each of these, there would only be +a few relevant lines of code, and as this would look fairly similar for each +of the plotters, the following stripped-down example shows just the case +for the :class:`SinglePlotter1D`: + +.. code-block:: + + class SinglePlotter1D(aspecd.plotting.SinglePlotter1D): + + def _create_plot(self): + super()._create_plot() + if self.data.axes[0].unit == "": + self.properties.axes.invert = ["x"] + + +Here, the unit of the *x* axis is checked and if it is set to a certain +value (replace the placeholder ```` with a reasonable value in +your code), the *x* axis is inverted. This is all functional code +necessary to achieve the requested behaviour. In a real class, you would +need to add a proper class docstring including examples how to use the +class. Get inspiration from either the ASpecD framework itself or one of +the derived packages. + + .. _sec:plotting:developers_data: Access to data for plotting From 1c6c3a0593da615c0b33cfbd744fd9a90be4437f Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Mon, 8 Jan 2024 21:50:04 +0100 Subject: [PATCH 54/55] Update documentation and changelog. Explain version number in recipe files. --- VERSION | 2 +- docs/changelog.rst | 4 +++- docs/usecases.rst | 14 +++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index c672a41..982cb96 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev79 +0.9.0.dev80 diff --git a/docs/changelog.rst b/docs/changelog.rst index a739249..11c84c1 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -50,7 +50,9 @@ New features Changes ------- -* Plotters can now handle device data instead of the primary data of a dataset (see above). This means, however, that instead of accessing ``self.dataset.data`` (or ``self.datasets[#].data``), plotters need to access ``self.data.data`` (or ``self.data[#].data``) instead. **Authors of derived packages should update their plotters accordingly.** See the :ref:`hints for developers on device data in the plotting module ` +* Plotters can now handle device data instead of the primary data of a dataset (see above). This means, however, that instead of accessing ``self.dataset.data`` (or ``self.datasets[#].data``), plotters need to access ``self.data.data`` (or ``self.data[#].data``) instead. + + **Authors of derived packages should update their plotters accordingly.** See the :ref:`hints for developers on device data in the plotting module `. * Serving recipes logs messages from all ASpecD modules, not only from the :mod:`aspecd.tasks` module. diff --git a/docs/usecases.rst b/docs/usecases.rst index 9a00644..19dcbb6 100644 --- a/docs/usecases.rst +++ b/docs/usecases.rst @@ -81,7 +81,19 @@ Therefore, in a recipe that is basically a YAML file, you will always find two k - ... -There are, however, a few additional (optional) keys that may appear on the highest level, setting such things as the :ref:`default package to use ` (for packages derived from ASpecD), the default source directory for datasets and the default output directory for figures and reports. +But what about the first block shown in the first example, the top-level ``format`` key? Let's have a look at it again: + + +.. code-block:: yaml + + format: + type: ASpecD recipe + version: '0.2' + + +This first block of every ASpecD recipe simply describes the file format, *e.g.* an ASpecD recipe, and the version of the format. Note that regardless of the package based on the ASpecD framework, the format type will always be "ASpecD recipe" (at least for the time being), and the version number is *independent* of the version number of the ASpecD framework or any derived package, but is an independent version number of the recipe file format as such. + +Besides the two essential blocks (datasets and tasks) mentioned above, a few additional (optional) keys may appear on the highest level, setting such things as the :ref:`default package to use ` (for packages derived from ASpecD), the default source directory for datasets and the default output directory for figures and reports. .. code-block:: yaml From 170b45addcfdf53639ab4854e4431ae9ed91b98b Mon Sep 17 00:00:00 2001 From: Till Biskup Date: Sat, 13 Jan 2024 21:02:58 +0100 Subject: [PATCH 55/55] Fix typo in documentation --- VERSION | 2 +- aspecd/analysis.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 982cb96..7c454b2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0.dev80 +0.9.0.dev81 diff --git a/aspecd/analysis.py b/aspecd/analysis.py index 3c32308..a676aaf 100644 --- a/aspecd/analysis.py +++ b/aspecd/analysis.py @@ -128,8 +128,8 @@ ========================== Each real analysis step should inherit from either -:class:`aspecd.processing.SingleProcessingStep` in case of operating on a -single dataset only or from :class:`aspecd.processing.MultiProcessingStep` in +:class:`aspecd.analysis.SingleAnalysisStep` in case of operating on a +single dataset only or from :class:`aspecd.analysis.MultiAnalysisStep` in case of operating on several datasets at once. Furthermore, all analysis steps should be contained in one module named "analysis". This allows for easy automation and replay of analysis steps, particularly in context of