From 6728859a984a3080f8fd4f1135de36bc17454098 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 9 Jan 2024 12:49:08 -0500 Subject: [PATCH] feat: add latex and plot style utilities (#132) * move figspec and latex utilities from mf6 examples repo * add matplotlib as optional dependency * update readme --- .github/workflows/ci.yml | 2 +- README.md | 10 +- autotest/test_figspec.py | 5 + autotest/test_latex.py | 0 docs/index.rst | 2 + docs/md/figspec.md | 33 +++ docs/md/latex.md | 3 + modflow_devtools/figspec.py | 561 ++++++++++++++++++++++++++++++++++++ modflow_devtools/latex.py | 97 +++++++ pyproject.toml | 4 + 10 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 autotest/test_figspec.py create mode 100644 autotest/test_latex.py create mode 100644 docs/md/figspec.md create mode 100644 docs/md/latex.md create mode 100644 modflow_devtools/figspec.py create mode 100644 modflow_devtools/latex.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd5a40c..c6227ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: working-directory: modflow-devtools run: | pip install . - pip install ".[test]" + pip install ".[test, optional]" - name: Cache modflow6 examples id: cache-examples diff --git a/README.md b/README.md index 1ae2c7d..7f04b87 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,13 @@ Python development tools for MODFLOW 6. ## Use cases -This is a small toolkit for developing MODFLOW 6, FloPy, and related projects. It includes standalone utilities and optional [Pytest](https://github.com/pytest-dev/pytest) extensions. +This is a small toolkit for developing MODFLOW 6, FloPy, and related projects. It includes standalone utilities and optional [Pytest](https://github.com/pytest-dev/pytest) and [Matplotlib](https://matplotlib.org/stable/) extensions. -The former include a very minimal GitHub API client for retrieving release information and downloading assets, a `ZipFile` subclass that [preserves file permissions](https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries) (workaround for [Python #15795](https://bugs.python.org/issue15795)), and other release/distribution-related tools. +Utilities include: + +* a minimal GitHub API client for retrieving release information and downloading assets +* a `ZipFile` subclass that [preserves file permissions](https://stackoverflow.com/questions/39296101/python-zipfile-removes-execute-permissions-from-binaries) (workaround for [Python #15795](https://bugs.python.org/issue15795)) +* other release/distribution-related tools Pytest features include: @@ -46,6 +50,8 @@ Pytest features include: - `MODFLOW-USGS/modflow6-testmodels` - `MODFLOW-USGS/modflow6-largetestmodels` +Matplotlib styles are provided in the `modflow_devtools.figspecs` module. + ## Requirements Python3.8+, dependency-free, but pairs well with `pytest` and select plugins, e.g. diff --git a/autotest/test_figspec.py b/autotest/test_figspec.py new file mode 100644 index 0000000..3337582 --- /dev/null +++ b/autotest/test_figspec.py @@ -0,0 +1,5 @@ +from modflow_devtools.figspec import USGSFigure + + +def test_usgs_figure(): + fig = USGSFigure() diff --git a/autotest/test_latex.py b/autotest/test_latex.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/index.rst b/docs/index.rst index e71195d..ebd2c55 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,8 @@ The `modflow-devtools` package provides a set of tools for developing and testin :caption: Miscellaneous md/download.md + md/figspec.md + md/latex.md md/ostags.md md/zip.md md/timed.md diff --git a/docs/md/figspec.md b/docs/md/figspec.md new file mode 100644 index 0000000..1399bae --- /dev/null +++ b/docs/md/figspec.md @@ -0,0 +1,33 @@ +# Plot styles + +Matplotlib is an optional dependency, installable via e.g. `pip install modflow_devtools[optional]`. + +## `USGSFigure` + +A convenience class `modflow_devtools.figspec.USGSFigure` is provided to create figures with the default USGS style sheet. For instance: + +```python +# create figure +fs = USGSFigure(figure_type="graph", verbose=False) + +# ...add some plots + +# add a heading +title = f"Layer {ilay + 1}" +letter = chr(ord("@") + idx + 2) +fs.heading(letter=letter, heading=title) + +# add an annotation +fs.add_annotation( + ax=ax, + text="Well 1, layer 2", + bold=False, + italic=False, + xy=w1loc, + xytext=(w1loc[0] - 3200, w1loc[1] + 1500), + ha="right", + va="center", + zorder=100, + arrowprops=arrow_props, +) +``` \ No newline at end of file diff --git a/docs/md/latex.md b/docs/md/latex.md new file mode 100644 index 0000000..61d76a5 --- /dev/null +++ b/docs/md/latex.md @@ -0,0 +1,3 @@ +# LaTeX utilities + +The `modflow_devtools.latex` module provides utility functions for building LaTeX tables from arrays. \ No newline at end of file diff --git a/modflow_devtools/figspec.py b/modflow_devtools/figspec.py new file mode 100644 index 0000000..568db57 --- /dev/null +++ b/modflow_devtools/figspec.py @@ -0,0 +1,561 @@ +import numpy as np + +from modflow_devtools.imports import import_optional_dependency + +mpl = import_optional_dependency("matplotlib") +import sys + +import numpy as np + + +class USGSFigure: + def __init__( + self, figure_type="map", family="Arial Narrow", verbose=False + ): + """Create a USGSFigure object + + Parameters + ---------- + figure_type : str + figure type ("map", "graph") + family : str + font family name (default is Arial Narrow) + verbose : bool + boolean that define if debug information should be written + """ + # initialize members + self.family = None + self.figure_type = None + self.verbose = verbose + self.family = self._set_fontfamily(family) + self.figure_type = self._validate_figure_type(figure_type) + + def graph_legend(self, ax=None, handles=None, labels=None, **kwargs): + """Add a USGS-style legend to a matplotlib axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + handles : list + list of legend handles + labels : list + list of labels for legend handles + kwargs : kwargs + matplotlib legend kwargs + + Returns + ------- + leg : object + matplotlib legend object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + font = self._set_fontspec(bold=True, italic=False) + if handles is None or labels is None: + handles, labels = ax.get_legend_handles_labels() + leg = ax.legend(handles, labels, prop=font, **kwargs) + + # add title to legend + if "title" in kwargs: + title = kwargs.pop("title") + else: + title = None + leg = self.graph_legend_title(leg, title=title) + return leg + + def graph_legend_title(self, leg, title=None): + """Set the legend title for a matplotlib legend object + + Parameters + ---------- + leg : legend object + matplotlib legend object + title : str + title for legend + + Returns + ------- + leg : object + matplotlib legend object + + """ + if title is None: + title = "EXPLANATION" + elif title.lower() == "none": + title = None + font = self._set_fontspec(bold=True, italic=False) + leg.set_title(title, prop=font) + return leg + + def heading( + self, ax=None, letter=None, heading=None, x=0.00, y=1.01, idx=None + ): + """Add a USGS-style heading to a matplotlib axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + letter : str + string that defines the subplot (A, B, C, etc.) + heading : str + text string + x : float + location of the heading in the x-direction in normalized plot dimensions + ranging from 0 to 1 (default is 0.00) + y : float + location of the heading in the y-direction in normalized plot dimensions + ranging from 0 to 1 (default is 1.01) + idx : int + index for programatically generating the heading letter when letter + is None and idx is not None. idx = 0 will generate A (default is None) + + Returns + ------- + text : object + matplotlib text object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + if letter is None and idx is not None: + letter = chr(ord("A") + idx) + + text = None + if letter is not None: + font = self._set_fontspec(bold=True, italic=True) + if heading is None: + letter = letter.replace(".", "") + else: + letter = letter.rstrip() + if letter[-1] != ".": + letter += "." + letter += " " + ax.text( + x, + y, + letter, + va="bottom", + ha="left", + fontdict=font, + transform=ax.transAxes, + ) + bbox = ax.get_window_extent().transformed( + mpl.pyplot.gcf().dpi_scale_trans.inverted() + ) + width = bbox.width * 25.4 # inches to mm + x += len(letter) * 1.0 / width + if heading is not None: + font = self._set_fontspec(bold=True, italic=False) + text = ax.text( + x, + y, + heading, + va="bottom", + ha="left", + fontdict=font, + transform=ax.transAxes, + ) + return text + + def add_text( + self, + ax=None, + text="", + x=0.0, + y=0.0, + transform=True, + bold=True, + italic=True, + fontsize=9, + ha="left", + va="bottom", + **kwargs, + ): + """Add USGS-style text to a axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + text : str + text string + x : float + x-location of text string (default is 0.) + y : float + y-location of text string (default is 0.) + transform : bool + boolean that determines if a transformed (True) or data (False) coordinate + system is used to define the (x, y) location of the text string + (default is True) + bold : bool + boolean indicating if bold font (default is True) + italic : bool + boolean indicating if italic font (default is True) + fontsize : int + font size (default is 9 points) + ha : str + matplotlib horizontal alignment keyword (default is left) + va : str + matplotlib vertical alignment keyword (default is bottom) + kwargs : dict + dictionary with valid matplotlib text object keywords + + Returns + ------- + text_obj : object + matplotlib text object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + if transform: + transform = ax.transAxes + else: + transform = ax.transData + + font = self._set_fontspec(bold=bold, italic=italic, fontsize=fontsize) + + text_obj = ax.text( + x, + y, + text, + va=va, + ha=ha, + fontdict=font, + transform=transform, + **kwargs, + ) + return text_obj + + def add_annotation( + self, + ax=None, + text="", + xy=None, + xytext=None, + bold=True, + italic=True, + fontsize=9, + ha="left", + va="bottom", + **kwargs, + ): + """Add an annotation to a axis object + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + text : str + text string + xy : tuple + tuple with the location of the annotation (default is None) + xytext : tuple + tuple with the location of the text + bold : bool + boolean indicating if bold font (default is True) + italic : bool + boolean indicating if italic font (default is True) + fontsize : int + font size (default is 9 points) + ha : str + matplotlib horizontal alignment keyword (default is left) + va : str + matplotlib vertical alignment keyword (default is bottom) + kwargs : dict + dictionary with valid matplotlib annotation object keywords + + Returns + ------- + ann_obj : object + matplotlib annotation object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + if xy is None: + xy = (0.0, 0.0) + + if xytext is None: + xytext = (0.0, 0.0) + + font = self._set_fontspec(bold=bold, italic=italic, fontsize=fontsize) + + # add font information to kwargs + if kwargs is None: + kwargs = font + else: + for key, value in font.items(): + kwargs[key] = value + + # create annotation + ann_obj = ax.annotate(text, xy, xytext, va=va, ha=ha, **kwargs) + + return ann_obj + + def remove_edge_ticks(self, ax=None): + """Remove unnecessary ticks on the edges of the plot + + Parameters + ---------- + ax : axis object + matplotlib axis object (default is None) + + Returns + ------- + ax : axis object + matplotlib axis object + + """ + if ax is None: + ax = mpl.pyplot.gca() + + # update tick objects + mpl.pyplot.draw() + + # get min and max value and ticks + ymin, ymax = ax.get_ylim() + + # check for condition where y-axis values are reversed + if ymax < ymin: + y = ymin + ymin = ymax + ymax = y + yticks = ax.get_yticks() + + if self.verbose: + print("y-axis: ", ymin, ymax) + print(yticks) + + # remove edge ticks on y-axis + ticks = ax.yaxis.majorTicks + for iloc in [0, -1]: + if np.allclose(float(yticks[iloc]), ymin): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + if np.allclose(float(yticks[iloc]), ymax): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + + # get min and max value and ticks + xmin, xmax = ax.get_xlim() + + # check for condition where x-axis values are reversed + if xmax < xmin: + x = xmin + xmin = xmax + xmax = x + + xticks = ax.get_xticks() + if self.verbose: + print("x-axis: ", xmin, xmax) + print(xticks) + + # remove edge ticks on y-axis + ticks = ax.xaxis.majorTicks + for iloc in [0, -1]: + if np.allclose(float(xticks[iloc]), xmin): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + if np.allclose(float(xticks[iloc]), xmax): + ticks[iloc].tick1line.set_visible = False + ticks[iloc].tick2line.set_visible = False + + return ax + + # private methods + + def _validate_figure_type(self, figure_type): + """Set figure type after validation of specified figure type + + Parameters + ---------- + figure_type : str + figure type ("map", "graph") + + Returns + ------- + figure_type : str + validated figure_type + + """ + # validate figure type + valid_types = ("map", "graph") + if figure_type not in valid_types: + errmsg = "invalid figure_type specified ({}) ".format( + figure_type + ) + "valid types are '{}'.".format(", ".join(valid_types)) + raise ValueError(errmsg) + + # set figure_type + if figure_type == "map": + self._set_map_specifications() + elif figure_type == "graph": + self._set_map_specifications() + + return figure_type + + def _set_graph_specifications(self): + """Set matplotlib rcparams to USGS-style specifications for graphs + + Returns + ------- + + """ + rc_dict = { + "font.family": self.family, + "font.size": 7, + "axes.labelsize": 9, + "axes.titlesize": 9, + "axes.linewidth": 0.5, + "xtick.labelsize": 8, + "xtick.top": True, + "xtick.bottom": True, + "xtick.major.size": 7.2, + "xtick.minor.size": 3.6, + "xtick.major.width": 0.5, + "xtick.minor.width": 0.5, + "xtick.direction": "in", + "ytick.labelsize": 8, + "ytick.left": True, + "ytick.right": True, + "ytick.major.size": 7.2, + "ytick.minor.size": 3.6, + "ytick.major.width": 0.5, + "ytick.minor.width": 0.5, + "ytick.direction": "in", + "pdf.fonttype": 42, + "savefig.dpi": 300, + "savefig.transparent": True, + "legend.fontsize": 9, + "legend.frameon": False, + "legend.markerscale": 1.0, + } + mpl.rcParams.update(rc_dict) + + def _set_map_specifications(self): + """Set matplotlib rcparams to USGS-style specifications for maps + + Returns + ------- + + """ + rc_dict = { + "font.family": self.family, + "font.size": 7, + "axes.labelsize": 9, + "axes.titlesize": 9, + "axes.linewidth": 0.5, + "xtick.labelsize": 7, + "xtick.top": True, + "xtick.bottom": True, + "xtick.major.size": 7.2, + "xtick.minor.size": 3.6, + "xtick.major.width": 0.5, + "xtick.minor.width": 0.5, + "xtick.direction": "in", + "ytick.labelsize": 7, + "ytick.left": True, + "ytick.right": True, + "ytick.major.size": 7.2, + "ytick.minor.size": 3.6, + "ytick.major.width": 0.5, + "ytick.minor.width": 0.5, + "ytick.direction": "in", + "pdf.fonttype": 42, + "savefig.dpi": 300, + "savefig.transparent": True, + "legend.fontsize": 9, + "legend.frameon": False, + "legend.markerscale": 1.0, + } + mpl.rcParams.update(rc_dict) + + def _set_fontspec( + self, + bold=True, + italic=True, + fontsize=9, + verbose=False, + ): + """Create fontspec dictionary for matplotlib pyplot objects + + Parameters + ---------- + bold : bool + boolean indicating if font is bold (default is True) + italic : bool + boolean indicating if font is italic (default is True) + fontsize : int + font size (default is 9 point) + + + Returns + ------- + + """ + univers = "Univers" in self.family + family = None if univers else self.family + + if bold: + weight = "bold" + if univers: + family = "Univers 67" + else: + weight = "normal" + if univers: + family = "Univers 57" + + if italic: + if univers: + family += " Condensed Oblique" + style = "oblique" + else: + style = "italic" + else: + if univers: + family += " Condensed" + style = "normal" + + # define fontspec dictionary + fontspec = { + "family": family, + "size": fontsize, + "weight": weight, + "style": style, + } + + if verbose: + sys.stdout.write("font specifications:\n ") + for key, value in fontspec.items(): + sys.stdout.write(f"{key}={value} ") + sys.stdout.write("\n") + + return fontspec + + def _set_fontfamily(self, family): + """Set font family to Liberation Sans Narrow on linux if default Arial Narrow + is being used + + Parameters + ---------- + family : str + font family name (default is Arial Narrow) + + Returns + ------- + family : str + font family name + + """ + if sys.platform.lower() in ("linux",): + if family == "Arial Narrow": + family = "Liberation Sans Narrow" + return family diff --git a/modflow_devtools/latex.py b/modflow_devtools/latex.py new file mode 100644 index 0000000..5d6f5eb --- /dev/null +++ b/modflow_devtools/latex.py @@ -0,0 +1,97 @@ +import os + + +def build_table(caption, fpth, arr, headings=None, col_widths=None): + if headings is None: + headings = arr.dtype.names + ncols = len(arr.dtype.names) + if not fpth.endswith(".tex"): + fpth += ".tex" + label = "tab:{}".format(os.path.basename(fpth).replace(".tex", "")) + + line = get_header(caption, label, headings, col_widths=col_widths) + + for idx in range(arr.shape[0]): + if idx % 2 != 0: + line += "\t\t\\rowcolor{Gray}\n" + line += "\t\t" + for jdx, name in enumerate(arr.dtype.names): + line += f"{arr[name][idx]}" + if jdx < ncols - 1: + line += " & " + line += " \\\\\n" + + # footer + line += get_footer() + + with open(fpth, "w") as f: + f.write(line) + + +def get_header( + caption, label, headings, col_widths=None, center=True, firsthead=False +): + ncol = len(headings) + if col_widths is None: + dx = 0.8 / float(ncol) + col_widths = [dx for idx in range(ncol)] + if center: + align = "p" + else: + align = "p" + + header = "\\small\n" + header += "\\begin{longtable}[!htbp]{\n" + for col_width in col_widths: + header += ( + 38 * " " + + f"{align}" + + f"{{{col_width}\\linewidth-2\\arraycolsep}}\n" + ) + header += 38 * " " + "}\n" + header += f"\t\\caption{{{caption}}} \\label{{{label}}} \\\\\n\n" + + if firsthead: + header += "\t\\hline \\hline\n" + header += "\t\\rowcolor{Gray}\n" + header += "\t" + for idx, s in enumerate(headings): + header += f"\\textbf{{{s}}}" + if idx < len(headings) - 1: + header += " & " + header += " \\\\\n" + header += "\t\\hline\n" + header += "\t\\endfirsthead\n\n" + + header += "\t\\hline \\hline\n" + header += "\t\\rowcolor{Gray}\n" + header += "\t" + for idx, s in enumerate(headings): + header += f"\\textbf{{{s}}}" + if idx < len(headings) - 1: + header += " & " + header += " \\\\\n" + header += "\t\\hline\n" + header += "\t\\endhead\n\n" + + return header + + +def get_footer(): + return "\t\\hline \\hline\n\\end{longtable}\n\\normalsize\n\n" + + +def exp_format(v): + s = f"{v:.2e}" + s = s.replace("e-0", "e-") + s = s.replace("e+0", "e+") + # s = s.replace("e", " \\times 10^{") + "}$" + return s + + +def float_format(v, fmt="{:.2f}"): + return fmt.format(v) + + +def int_format(v): + return f"{v:d}" diff --git a/pyproject.toml b/pyproject.toml index 4d2a701..fe49f7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,10 @@ lint = [ "isort", "pylint" ] +optional = [ + "matplotlib", + "pytest", +] test = [ "modflow-devtools[lint]", "coverage",