diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15b0f90e2..fa9b7b567 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,19 @@ - New ``Spectrum1D.to_text_file`` and ``Spectrum1DCollection.to_text_file`` methods to write to column text files + - An expanded and consistent set of styling options is made + available for command-line tools that produce plots. + + - Consistent styling and advanced changes can be made using + Matplotlib stylesheet files, either as a CLI argument or + using ``matplotlib.style.context()`` in a Python script. + +- Improvements: + + - Internally, plot theming has been adjusted to rely on Matplotlib + style contexts. This means user changes and style context are more + likely to be respected. + `v0.6.2 `_ ------ diff --git a/MANIFEST.in b/MANIFEST.in index b28b802bd..9e70f95e2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,7 @@ include euphonic/LICENSE include euphonic/CITATION.cff include euphonic/data/* include euphonic/_version.py +include euphonic/styles/*.mplstyle include versioneer.py include c/*.h include c/*.c diff --git a/doc/source/cl-tools.rst b/doc/source/cl-tools.rst index 75207c7f8..e742643d9 100644 --- a/doc/source/cl-tools.rst +++ b/doc/source/cl-tools.rst @@ -12,3 +12,4 @@ Command-line Tools euphonic-powder-map euphonic-optimise-dipole-parameter euphonic-show-sampling + Customising plots diff --git a/doc/source/dipole-parameter-script.rst b/doc/source/dipole-parameter-script.rst index a14f7063c..48ef750de 100644 --- a/doc/source/dipole-parameter-script.rst +++ b/doc/source/dipole-parameter-script.rst @@ -1,10 +1,11 @@ .. _dipole-parameter-script: -.. highlight:: bash ================================== euphonic-optimise-dipole-parameter ================================== +.. highlight:: bash + This program is useful for users wanting to efficiently calculate phonon frequencies on many q-points for polar materials. diff --git a/doc/source/disp-script.rst b/doc/source/disp-script.rst index 33bffa070..164fb9467 100644 --- a/doc/source/disp-script.rst +++ b/doc/source/disp-script.rst @@ -1,10 +1,11 @@ .. _disp-script: -.. highlight:: bash =================== euphonic-dispersion =================== +.. highlight:: bash + The ``euphonic-dispersion`` program can be used to plot dispersion either along a specific trajectory from precalculated phonon frequencies, or along a recommended reciprocal space path from force constants. For @@ -23,7 +24,8 @@ To see all the command line options, run:: euphonic-dispersion -h -You can also see the available command line options below +You can also see the available command line options below. +For information on advanced plot styling, see :ref:`styling`. Command Line Options -------------------- diff --git a/doc/source/dos-script.rst b/doc/source/dos-script.rst index 94537e2b1..a5b6a966c 100644 --- a/doc/source/dos-script.rst +++ b/doc/source/dos-script.rst @@ -1,10 +1,11 @@ .. _dos-script: -.. highlight:: bash ============ euphonic-dos ============ +.. highlight:: bash + The ``euphonic-dos`` program can be used to plot density of states, partial density of states, and/or neutron-weighted density of states. It can use pre-calculated frequencies, or use force constants to @@ -22,7 +23,8 @@ To see all the command line options, run:: euphonic-dos -h -You can also see the available command line options below +You can also see the available command line options below. +For information on advanced plot styling, see :ref:`styling`. Command Line Options -------------------- diff --git a/doc/source/figures/plot-styling-custom-1.png b/doc/source/figures/plot-styling-custom-1.png new file mode 100644 index 000000000..ba7470e23 Binary files /dev/null and b/doc/source/figures/plot-styling-custom-1.png differ diff --git a/doc/source/figures/plot-styling-custom-2.png b/doc/source/figures/plot-styling-custom-2.png new file mode 100644 index 000000000..c4fb665f7 Binary files /dev/null and b/doc/source/figures/plot-styling-custom-2.png differ diff --git a/doc/source/figures/plot-styling-seaborn.png b/doc/source/figures/plot-styling-seaborn.png new file mode 100644 index 000000000..f7eabdd4d Binary files /dev/null and b/doc/source/figures/plot-styling-seaborn.png differ diff --git a/doc/source/intensity-map-script.rst b/doc/source/intensity-map-script.rst index 7ddda6a9f..d5bc96fb0 100644 --- a/doc/source/intensity-map-script.rst +++ b/doc/source/intensity-map-script.rst @@ -1,10 +1,11 @@ .. _intensity-map-script: -.. highlight:: bash ====================== euphonic-intensity-map ====================== +.. highlight:: bash + The ``euphonic-intensity-map`` program can be used to plot a 2D intensity map either along a specific trajectory from precalculated phonon frequencies and eigenvectors, or along a recommended reciprocal space path from force @@ -23,7 +24,8 @@ To see all the command line options, run:: euphonic-intensity-map -h -You can also see the available command line options below +You can also see the available command line options below. +For information on advanced plot styling, see :ref:`styling`. Command Line Options -------------------- diff --git a/doc/source/plotting.rst b/doc/source/plotting.rst index a4f6d757a..2e29f8904 100644 --- a/doc/source/plotting.rst +++ b/doc/source/plotting.rst @@ -166,3 +166,30 @@ Docstrings .. autofunction:: euphonic.plot.plot_2d .. autofunction:: euphonic.plot.plot_2d_to_axis + +Styling +======= + +To produce consistent and beautiful plots, it is recommended to use +`Matplotlib style sheets `_. +The cleanest way to apply this is using a context manager. +Within the indented block, a user-provided combination of style sheets +is applied to any new plots. +These can be built-in themes, file paths or parameter dictionaries, +e.g.: + +.. code-block:: py + + import matplotlib.pyplot as plt + from euphonic import Spectrum1D + from euphonic.plot import plot_1d, plot_1d_to_axis + + dos = Spectrum1D.from_json_file('dos.json') + + with plt.style.context(['dark_background', {'lines.linewidth': 2.0}]): + fig = plot_1d(dos) + fig.show() + +This approach is used in the Euphonic command-line tools; for more +information see :ref:`styling`. The CLI defaults can be imitated by +using the same style sheet ``euphonic.style.base_style``. diff --git a/doc/source/powder-map-script.rst b/doc/source/powder-map-script.rst index 6568318a1..481ea1b2a 100644 --- a/doc/source/powder-map-script.rst +++ b/doc/source/powder-map-script.rst @@ -1,10 +1,11 @@ .. _powder-map-script: -.. highlight:: bash ====================== euphonic-powder-map ====================== +.. highlight:: bash + The ``euphonic-powder-map`` program can be used to sample spherically-averaged properties from force constants data over a range of :math:`|q|`. The results are plotted as a 2-dimensional map in :math:`(|q|, \omega)`. @@ -23,6 +24,7 @@ To see all the command line options, run:: euphonic-powder-map -h You can also see the available command line options at the bottom of this page. +For information on advanced plot styling, see :ref:`styling`. Spherical averaging options --------------------------- diff --git a/doc/source/sampling-script.rst b/doc/source/sampling-script.rst index cae5590ff..7fd773149 100644 --- a/doc/source/sampling-script.rst +++ b/doc/source/sampling-script.rst @@ -1,10 +1,11 @@ .. _sampling-script: -.. highlight:: bash ====================== euphonic-show-sampling ====================== +.. highlight:: bash + ``euphonic-show-sampling`` can be used to visualise the spherical sampling schemes implemented in :mod:`euphonic.sampling`. For example, to see how the 'golden' sphere sampling approach works for diff --git a/doc/source/styling.rst b/doc/source/styling.rst new file mode 100644 index 000000000..ca329ea51 --- /dev/null +++ b/doc/source/styling.rst @@ -0,0 +1,108 @@ +.. _styling: + +================= +Customising plots +================= + +.. highlight:: bash + +Command-line options +==================== + +Several of Euphonic's :ref:`cl-tools` produce 2D plots. A few command-line +arguments are provided to tweak the plot size, font settings and colour maps, e.g.:: + + euphonic-dos --font Monaco --fontsize 18 --figsize 10 10 --figsize-unit cm quartz.castep_bin + +will produce a plot with larger (hopefully monospace) text on a small +square canvas. This may be especially useful with the ``--save-to`` +option to create consistent plots for presentation slides and academic +papers. + +The ``--font`` option will be passed to the Matplotlib library as the +preferred "sans-serif" option, and the font family will be set to +sans-serif. Unfortunately it can be tricky to identify exactly which +font names are accepted by Matplotlib. We cannot advise on this for +all platforms, so if you have a preferred font it may be worth +searching for help on using this font with Matplotlib. + +Using Matplotlib styles +======================= + +These and other appearance customisations can be defined as a +Matplotlib style sheet. +`A number of popular styles are predefined in Matplotlib `_ +and may be accessed by name, e.g.:: + + euphonic-dos --style seaborn quartz.castep_bin + +will yield a plot on a grey background with white gridlines. + +.. image:: figures/plot-styling-seaborn.png + :width: 400 + :alt: PDOS plot with thin dark blue, red and green lines against a + pale grey background divided by white gridlines. There are no + outlines around the legend (top-right) or the axes; number + values are in a black sans-serif and float near the plot. + +``--style=dark_background`` might be preferred for some slide +presentations. + +Using custom stylesheets +======================== + +For a custom scheme, you can `develop your own style file `_. +For example, with the following file saved as "custom.mplstyle" + +.. code-block:: ini + + axes.facecolor: floralwhite + font.family: monospace + text.color: grey + lines.linewidth : 3 + xtick.labelsize : smaller + legend.fancybox: True + legend.shadow: True + figure.figsize : 3, 3 + figure.facecolor : palegoldenrod + +then the command:: + + euphonic-dos quartz.castep_bin --pdos --style custom.mplstyle + +generates a small figure with some "opinionated" styling. + +.. image:: figures/plot-styling-custom-1.png + :width: 400 + :alt: A square PDOS plot with very thick blue, orange and green data + lines, a pale yellow background and pinkish off-white canvas. + The canvas is surrounded by black lines with numbered ticks. + On the canvas are vertical grey gridlines and a legend with drop + shadow, round corners and grey text. The label text is in a + monospace font. + +It is possible to "compose" multiple styles in ascending priority +order, e.g.:: + + euphonic-dos quartz.castep_bin --pdos --style seaborn custom.mplstyle + +In the resulting figure, the customised text and canvas options have +taken priority, but we still get the Seaborn colour sequence for plot +lines. The plot outline, ticks and legend box were removed. (And with +them, the legend customisation!) + +.. image:: figures/plot-styling-custom-2.png + :width: 400 + :alt: A very similar plot to the above, except that the legend box + is gone (along with its shadow), and the line colours are now + a tasteful blue, green and (desaturated) red combination. The + grid lines are white against a pale orange background. + +For a large project, this can be very useful to establish a general +"house style" with variations for certain plot types. However, as seen +above, combining styles can sometimes have unexpected consequences. In +order to prevent conflict between Euphonic's own stylesheet and other +style options, the ``--no-base-style`` argument can be used to remove +the Euphonic defaults. For example, with the ``seaborn`` style this +will restore the horizontal grid lines that are expected to replace +the missing black ticks. diff --git a/euphonic/cli/dispersion.py b/euphonic/cli/dispersion.py index 21406da47..d8bf68a67 100644 --- a/euphonic/cli/dispersion.py +++ b/euphonic/cli/dispersion.py @@ -1,10 +1,14 @@ from argparse import ArgumentParser from typing import List, Optional +import matplotlib.style + import euphonic from euphonic.plot import plot_1d +from euphonic.styles import base_style from euphonic import Spectrum1D from .utils import (load_data_from_file, get_args, _bands_from_force_constants, + _compose_style, _get_q_distance, matplotlib_save_or_show, _get_cli_parser, _calc_modes_kwargs) @@ -50,13 +54,16 @@ def main(params: Optional[List[str]] = None) -> None: spectra = spectrum.split(**split_args) # type: List[Spectrum1D] - _ = plot_1d(spectra, - title=args.title, - x_label=x_label, - y_label=y_label, - y_min=args.e_min, y_max=args.e_max, - lw=1.0) - matplotlib_save_or_show(save_filename=args.save_to) + style = _compose_style(user_args=args, + base=[base_style]) + + with matplotlib.style.context(style): + _ = plot_1d(spectra, + title=args.title, + x_label=x_label, + y_label=y_label, + y_min=args.e_min, y_max=args.e_max) + matplotlib_save_or_show(save_filename=args.save_to) def get_parser() -> ArgumentParser: diff --git a/euphonic/cli/dos.py b/euphonic/cli/dos.py index 266b12a42..4e83d3a86 100644 --- a/euphonic/cli/dos.py +++ b/euphonic/cli/dos.py @@ -1,12 +1,14 @@ from argparse import ArgumentParser from typing import List, Optional -from euphonic import (ureg, ForceConstants, QpointPhononModes, - Spectrum1DCollection) +import matplotlib.style + +from euphonic import ureg, ForceConstants, QpointPhononModes from euphonic.util import mp_grid, mode_gradients_to_widths from euphonic.plot import plot_1d +from euphonic.styles import base_style from .utils import (load_data_from_file, get_args, matplotlib_save_or_show, - _calc_modes_kwargs, + _calc_modes_kwargs, _compose_style, _get_cli_parser, _get_energy_bins, _grid_spec_from_args, _get_pdos_weighting, _arrange_pdos_groups) @@ -74,9 +76,11 @@ def main(params: Optional[List[str]] = None) -> None: else: y_label = args.y_label - fig = plot_1d(dos, title=args.title, x_label=x_label, y_label=y_label, - y_min=0, lw=1.0) - matplotlib_save_or_show(save_filename=args.save_to) + style = _compose_style(user_args=args, base=[base_style]) + with matplotlib.style.context(style): + _ = plot_1d(dos, title=args.title, x_label=x_label, y_label=y_label, + y_min=0) + matplotlib_save_or_show(save_filename=args.save_to) def get_parser() -> ArgumentParser: diff --git a/euphonic/cli/intensity_map.py b/euphonic/cli/intensity_map.py index d8b1e9123..070a0b052 100755 --- a/euphonic/cli/intensity_map.py +++ b/euphonic/cli/intensity_map.py @@ -1,13 +1,16 @@ from argparse import ArgumentParser from typing import List, Optional +import matplotlib.style import numpy as np import euphonic from euphonic import ureg, Spectrum2D import euphonic.plot from euphonic.util import get_qpoint_labels +from euphonic.styles import base_style from .utils import (_bands_from_force_constants, _calc_modes_kwargs, + _compose_style, get_args, _get_debye_waller, _get_energy_bins, _get_q_distance, _get_cli_parser, load_data_from_file, @@ -91,13 +94,15 @@ def main(params: Optional[List[str]] = None) -> None: if len(spectra) > 1: print(f"Found {len(spectra)} regions in q-point path") - euphonic.plot.plot_2d(spectra, - cmap=args.cmap, - vmin=args.v_min, vmax=args.v_max, - x_label=x_label, - y_label=y_label, - title=args.title) - matplotlib_save_or_show(save_filename=args.save_to) + style = _compose_style(user_args=args, base=[base_style]) + with matplotlib.style.context(style): + + euphonic.plot.plot_2d(spectra, + vmin=args.v_min, vmax=args.v_max, + x_label=x_label, + y_label=y_label, + title=args.title) + matplotlib_save_or_show(save_filename=args.save_to) def get_parser() -> ArgumentParser: diff --git a/euphonic/cli/powder_map.py b/euphonic/cli/powder_map.py index 33f78d59c..8f425c950 100755 --- a/euphonic/cli/powder_map.py +++ b/euphonic/cli/powder_map.py @@ -2,10 +2,12 @@ from math import ceil from typing import List, Optional +import matplotlib.style import numpy as np from euphonic import ureg -from euphonic.cli.utils import (_calc_modes_kwargs, _get_cli_parser, +from euphonic.cli.utils import (_calc_modes_kwargs, _compose_style, + _get_cli_parser, _get_debye_waller, _get_energy_bins, _get_q_distance, _get_pdos_weighting, _arrange_pdos_groups) @@ -14,6 +16,7 @@ import euphonic.plot from euphonic.powder import (sample_sphere_dos, sample_sphere_pdos, sample_sphere_structure_factor) +from euphonic.styles import base_style, intensity_widget_style import euphonic.util # Dummy tqdm function if tqdm progress bars unavailable @@ -161,46 +164,50 @@ def main(params: Optional[List[str]] = None) -> None: else: x_label = args.x_label - fig = euphonic.plot.plot_2d(spectrum, - cmap=args.cmap, - vmin=args.v_min, vmax=args.v_max, - x_label=x_label, - y_label=y_label, - title=args.title) - - if args.disable_widgets is False: - # TextBox only available from mpl 2.1.0 - try: - from matplotlib.widgets import TextBox - except ImportError: - args.disable_widgets = True - - if args.disable_widgets is False: - min_label = f'Min Intensity ({spectrum.z_data.units:~P})' - max_label = f'Max Intensity ({spectrum.z_data.units:~P})' - boxw = 0.15 - boxh = 0.05 - x0 = 0.1 + len(min_label)*0.01 - y0 = 0.025 - fig.subplots_adjust(bottom=0.25) - axmin = fig.add_axes([x0, y0, boxw, boxh]) - axmax = fig.add_axes([x0, y0 + 0.075, boxw, boxh]) - image = fig.get_axes()[0].images[0] - cmin, cmax = image.get_clim() - pad = 0.05 - fmt_str = '.2e' if cmax < 0.1 else '.2f' - minbox = TextBox(axmin, min_label, - initial=f'{cmin:{fmt_str}}', label_pad=pad) - maxbox = TextBox(axmax, max_label, - initial=f'{cmax:{fmt_str}}', label_pad=pad) - def update_min(min_val): - image.set_clim(vmin=float(min_val)) - fig.canvas.draw() - - def update_max(max_val): - image.set_clim(vmax=float(max_val)) - fig.canvas.draw() - minbox.on_submit(update_min) - maxbox.on_submit(update_max) - - matplotlib_save_or_show(save_filename=args.save_to) + if args.disable_widgets: + base = [base_style] + else: + base = [base_style, intensity_widget_style] + style = _compose_style(user_args=args, base=base) + with matplotlib.style.context(style): + fig = euphonic.plot.plot_2d(spectrum, + vmin=args.v_min, vmax=args.v_max, + x_label=x_label, + y_label=y_label, + title=args.title) + + if args.disable_widgets is False: + # TextBox only available from mpl 2.1.0 + try: + from matplotlib.widgets import TextBox + except ImportError: + args.disable_widgets = True + + if args.disable_widgets is False: + min_label = f'Min Intensity ({spectrum.z_data.units:~P})' + max_label = f'Max Intensity ({spectrum.z_data.units:~P})' + boxw = 0.15 + boxh = 0.05 + x0 = 0.1 + len(min_label)*0.01 + y0 = 0.025 + axmin = fig.add_axes([x0, y0, boxw, boxh]) + axmax = fig.add_axes([x0, y0 + 0.075, boxw, boxh]) + image = fig.get_axes()[0].images[0] + cmin, cmax = image.get_clim() + pad = 0.05 + fmt_str = '.2e' if cmax < 0.1 else '.2f' + minbox = TextBox(axmin, min_label, + initial=f'{cmin:{fmt_str}}', label_pad=pad) + maxbox = TextBox(axmax, max_label, + initial=f'{cmax:{fmt_str}}', label_pad=pad) + def update_min(min_val): + image.set_clim(vmin=float(min_val)) + fig.canvas.draw() + + def update_max(max_val): + image.set_clim(vmax=float(max_val)) + fig.canvas.draw() + minbox.on_submit(update_min) + maxbox.on_submit(update_max) + + matplotlib_save_or_show(save_filename=args.save_to) diff --git a/euphonic/cli/utils.py b/euphonic/cli/utils.py index 4dc1eeadc..6c4d47534 100644 --- a/euphonic/cli/utils.py +++ b/euphonic/cli/utils.py @@ -640,6 +640,29 @@ def __call__(self, parser, args, values, option_string=None): dest='x_label', help='Plot x-axis label') section.add_argument('--y-label', type=str, default=None, dest='y_label', help='Plot y-axis label') + section.add_argument('--style', type=str, nargs='+', + help='Matplotlib styles (name or file)') + section.add_argument('--no-base-style', action='store_true', + dest='no_base_style', + help=('Remove all default formatting before ' + 'applying other style options.')) + section.add_argument('--font', type=str, default=None, + help=('Select text font. (This has to be a name ' + 'known to Matplotlib. font-family will be ' + 'set to sans-serif; it doesn\'t matter if)' + 'the font is actually sans-serif.')) + section.add_argument('--fontsize', type=float, default=None, + help='Set base font size in pt.') + section.add_argument('--figsize', type=float, nargs=2, default=None, + help='Figure canvas size in FIGSIZE-UNITS') + section.add_argument('--figsize-unit', type=str, default='cm', + dest='figsize_unit', + help='Unit of length for --figsize') + + if ('plotting' in features) and not ('map' in features): + section = sections['plotting'] + section.add_argument('--linewidth', type=float, default=None, + help='Set line width in pt.') if {'ebins', 'q-e'}.intersection(features): section = sections['energy'] @@ -709,7 +732,7 @@ def __call__(self, parser, args, values, option_string=None): '--v-max', type=float, default=None, dest='v_max', help='Maximum of data range for colormap.') sections['plotting'].add_argument( - '--cmap', type=str, default='viridis', help='Matplotlib colormap') + '--cmap', type=str, default=None, help='Matplotlib colormap') if 'btol' in features: sections['q'].add_argument( @@ -748,3 +771,53 @@ def __call__(self, parser, args, values, option_string=None): ) return parser, sections + + +MplStyle = Union[str, Dict[str, str]] + + +def _compose_style( + *, user_args: Namespace, base: Optional[List[MplStyle]] + ) -> List[MplStyle]: + """Combine user-specified style options with default stylesheets + + Args: + user_args: from _get_cli_parser().parse_args() + base: Euphonic default styles for this plot + + N.B. matplotlib applies styles from left to right, so the right-most + elements of the list take the highest priority. This function builds a + list in the order: + + [base style(s), user style(s), CLI arguments] + """ + + if user_args.no_base_style or base is None: + style = [] + else: + style = base + + if user_args.style: + style += user_args.style + + # Explicit args take priority over any other + explicit_args = {} + for user_arg, mpl_property in {'cmap': 'image.cmap', + 'fontsize': 'font.size', + 'font': 'font.sans-serif', + 'linewidth': 'lines.linewidth', + 'figsize': 'figure.figsize'}.items(): + if getattr(user_args, user_arg, None): + explicit_args.update({mpl_property: getattr(user_args, user_arg)}) + + if 'font.sans-serif' in explicit_args: + explicit_args.update({'font.family': 'sans-serif'}) + + if 'figure.figsize' in explicit_args: + dimensioned_figsize = [dim * ureg(user_args.figsize_unit) + for dim in explicit_args['figure.figsize']] + explicit_args['figure.figsize'] = [dim.to('inches').magnitude + for dim in dimensioned_figsize] + + style.append(explicit_args) + return style diff --git a/euphonic/plot.py b/euphonic/plot.py index dc82320d0..0f17a24e5 100644 --- a/euphonic/plot.py +++ b/euphonic/plot.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Sequence, Tuple, Union +from typing import Optional, Sequence, Tuple, Union try: import matplotlib.pyplot as plt @@ -54,7 +54,8 @@ def plot_1d_to_axis(spectra: Union[Spectrum1D, Spectrum1DCollection], raise TypeError("spectra should be a Spectrum1D or " "Spectrum1DCollection") - if isinstance(labels, str): labels = [labels] + if isinstance(labels, str): + labels = [labels] if labels is not None and len(labels) != len(spectra): raise ValueError( f"The length of labels (got {len(labels)}) should be the " @@ -186,6 +187,7 @@ def plot_1d(spectra: Union[Spectrum1D, # Add an invisible large axis for common labels ax = fig.add_subplot(111, frameon=False) + ax.grid(False) ax.tick_params(labelcolor="none", bottom=False, left=False) ax.set_xlabel(x_label) ax.set_ylabel(y_label) @@ -195,7 +197,7 @@ def plot_1d(spectra: Union[Spectrum1D, def plot_2d_to_axis(spectrum: Spectrum2D, ax: Axes, - cmap: Union[str, Colormap] = 'viridis', + cmap: Union[str, Colormap] = None, interpolation: str = 'nearest', norm: Optional[Normalize] = None, ) -> NonUniformImage: @@ -247,7 +249,7 @@ def plot_2d_to_axis(spectrum: Spectrum2D, ax: Axes, def plot_2d(spectra: Union[Spectrum2D, Sequence[Spectrum2D]], vmin: Optional[float] = None, vmax: Optional[float] = None, - cmap: Union[str, Colormap] = 'viridis', + cmap: Optional[Union[str, Colormap]] = None, title: str = '', x_label: str = '', y_label: str = '') -> Figure: """ Creates a Matplotlib figure for a Spectrum2D object @@ -312,15 +314,16 @@ def _get_minmax_intensity(spectrum: Spectrum2D) -> Tuple[float, float]: # Add an invisible large axis for common labels ax = fig.add_subplot(111, frameon=False) + ax.grid(False) ax.tick_params(labelcolor="none", bottom=False, left=False) ax.set_xlabel(x_label) ax.set_ylabel(y_label) fig.suptitle(title) - fig.tight_layout() return fig + def _set_x_tick_labels(ax: Axes, x_tick_labels: Optional[Sequence[Tuple[int, str]]], x_data: Quantity) -> None: @@ -328,7 +331,7 @@ def _set_x_tick_labels(ax: Axes, locs, labels = [list(x) for x in zip(*x_tick_labels)] x_values = x_data.magnitude # type: np.ndarray ax.set_xticks(x_values[locs]) - ax.xaxis.grid(True, which='major') + # Rotate long tick labels if len(max(labels, key=len)) >= 11: ax.set_xticklabels(labels, rotation=90) diff --git a/euphonic/styles/__init__.py b/euphonic/styles/__init__.py new file mode 100644 index 000000000..94885cb72 --- /dev/null +++ b/euphonic/styles/__init__.py @@ -0,0 +1,6 @@ +"""Matplotlib stylesheets for plot styling""" +from pkg_resources import resource_filename + +base_style = resource_filename("euphonic.styles", "base.mplstyle") +intensity_widget_style = resource_filename("euphonic.styles", + "intensity_widget.mplstyle") diff --git a/euphonic/styles/base.mplstyle b/euphonic/styles/base.mplstyle new file mode 100644 index 000000000..36d4d847e --- /dev/null +++ b/euphonic/styles/base.mplstyle @@ -0,0 +1,11 @@ +axes.grid : True +axes.grid.axis : x +axes.grid.which : major +axes.labelsize: medium +figure.titlesize : large +xtick.labelsize : small +ytick.labelsize : small +lines.linewidth : 1.0 +image.cmap : viridis +figure.autolayout : True # Equivalent to calling tight_layout(); + # consider setting to false if making adjustments \ No newline at end of file diff --git a/euphonic/styles/intensity_widget.mplstyle b/euphonic/styles/intensity_widget.mplstyle new file mode 100644 index 000000000..2025571e8 --- /dev/null +++ b/euphonic/styles/intensity_widget.mplstyle @@ -0,0 +1,2 @@ +figure.autolayout : False +figure.subplot.bottom: 0.25 \ No newline at end of file diff --git a/setup.py b/setup.py index a908f12a2..8867be4f9 100644 --- a/setup.py +++ b/setup.py @@ -102,7 +102,8 @@ def run_setup(): packages = ['euphonic', 'euphonic.cli', 'euphonic.readers', - 'euphonic.data'] + 'euphonic.data', + 'euphonic.styles'] with open('README.rst', 'r') as f: long_description = f.read() diff --git a/tests_and_analysis/test/script_tests/test_intensity_map.py b/tests_and_analysis/test/script_tests/test_intensity_map.py index e0781a394..e4a3f39f6 100644 --- a/tests_and_analysis/test/script_tests/test_intensity_map.py +++ b/tests_and_analysis/test/script_tests/test_intensity_map.py @@ -64,6 +64,7 @@ def run_intensity_map_and_test_result( self, intensity_map_args): euphonic.cli.intensity_map.main(intensity_map_args) + matplotlib.pyplot.gcf().tight_layout() # Force tick labels to be set image_data = get_current_plot_image_data() with open(intensity_map_output_file, 'r') as expected_data_file: diff --git a/tests_and_analysis/test/script_tests/test_powder_map.py b/tests_and_analysis/test/script_tests/test_powder_map.py index 404ca5ab4..00de6a7f7 100644 --- a/tests_and_analysis/test/script_tests/test_powder_map.py +++ b/tests_and_analysis/test/script_tests/test_powder_map.py @@ -71,6 +71,7 @@ def teardown_method(self): def run_powder_map_and_test_result(self, powder_map_args): euphonic.cli.powder_map.main(powder_map_args) + matplotlib.pyplot.gcf().tight_layout() # Force tick labels to be set image_data = get_current_plot_image_data() with open(powder_map_output_file, 'r') as expected_data_file: diff --git a/tests_and_analysis/test/script_tests/test_styling.py b/tests_and_analysis/test/script_tests/test_styling.py new file mode 100644 index 000000000..096574263 --- /dev/null +++ b/tests_and_analysis/test/script_tests/test_styling.py @@ -0,0 +1,80 @@ +from argparse import Namespace + +import pytest +import matplotlib.pyplot +from numpy.testing import assert_allclose + +import euphonic.cli.dos +from euphonic.cli.utils import _compose_style + +from tests_and_analysis.test.utils import get_castep_path + +compose_style_cases = [ + ({'user_args': Namespace(unused=1, no_base_style=False, style=None), + 'base': None}, [{}]), + ({'user_args': Namespace(unused=1, no_base_style=False, style=None), + 'base': ['dark_background']}, ['dark_background', {}]), + ({'user_args': Namespace(unused=1, no_base_style=True, style=None), + 'base': ['dark_background']}, [{}]), + ({'user_args': Namespace(no_base_style=False, style=None), + 'base': ['my/imaginary/file', 'dark_background']}, + ['my/imaginary/file', 'dark_background', {}]), + ({'user_args': Namespace(no_base_style=False, style=['ggplot', 'seaborn']), + 'base': ['my/imaginary/file', 'dark_background']}, + ['my/imaginary/file', 'dark_background', 'ggplot', 'seaborn', {}]), + ({'user_args': Namespace(unused=1, no_base_style=False, style=['ggplot'], + cmap='bone', fontsize=12, font='Comic Sans', + linewidth=4, figsize=[1, 2], figsize_unit='inch'), + 'base': None}, ['ggplot', {'image.cmap': 'bone', + 'font.size': 12, + 'font.family': 'sans-serif', + 'font.sans-serif': 'Comic Sans', + 'lines.linewidth': 4, + 'figure.figsize': [1, 2]}]), + ({'user_args': Namespace(unused=1, no_base_style=False, style=['ggplot'], + figsize=[2.54, 2.54], figsize_unit='cm'), + 'base': None}, ['ggplot', {'figure.figsize': [1., 1.]}]), + ] + + +@pytest.mark.unit +@pytest.mark.parametrize('kwargs,expected_style', compose_style_cases) +def test_compose_style(kwargs, expected_style): + """Internal function which interprets matplotlib style options""" + assert _compose_style(**kwargs) == expected_style + + +@pytest.mark.integration +class TestDOSStyling: + + @pytest.fixture + def inject_mocks(self, mocker): + # Prevent calls to show so we can get the current figure using + # gcf() + mocker.patch('matplotlib.pyplot.show') + mocker.resetall() + + def teardown_method(self): + # Ensure figures are closed + matplotlib.pyplot.close('all') + + def test_dos_styling(self, inject_mocks): + nah_phonon_file = get_castep_path('NaH', 'NaH.phonon') + + params = [nah_phonon_file, + '--style=dark_background', + '--linewidth=5.', + '--fontsize=11.', + '--figsize', '4', '4', + '--figsize-unit', 'inch'] + + euphonic.cli.dos.main(params=params) + + fig = matplotlib.pyplot.gcf() + + assert_allclose([0., 0., 0., 1.], fig.get_facecolor()) + assert fig.axes[0].lines[0].get_linewidth() == pytest.approx(5.) + assert_allclose([4., 4.], fig.get_size_inches()) + # Font size: base size 11 * "small" factor 0.833 + assert (fig.axes[0].get_xticklabels()[0].get_fontsize() + == pytest.approx(0.833 * 11))