diff --git a/docs/Users_Guide/release-notes.rst b/docs/Users_Guide/release-notes.rst index 9f4a6ad85..787730f09 100644 --- a/docs/Users_Guide/release-notes.rst +++ b/docs/Users_Guide/release-notes.rst @@ -7,8 +7,9 @@ describes the bugfix, enhancement, or new feature: `METplotpy GitHub issues. `_ -METplotpy Version 3.2.0-RC1 release notes (20250930) -==================================================== +METplotpy Version 4.0.0-beta1 release notes (20260205) +====================================================== + .. dropdown:: New Plots @@ -16,12 +17,12 @@ METplotpy Version 3.2.0-RC1 release notes (20250930) .. dropdown:: Enhancements - * Wind Rose: support specifying range radialaxes range (`#534 `_) - * Support YAML config files generated by METviewer MVBatch (`#540 `_) + * None .. dropdown:: Bugfixes - * Bugfix Vertical Plot fix incorrect x-values/x-axis labels (`#537 `_) + * Use the METviewer data output directory when the points path is not defined in the yaml config file created by METviewer (`#552 `_) + * WindRose plot shows incorrect legend when max wind speed is less than requested (`#547 `_) .. dropdown:: Documentation diff --git a/docs/Users_Guide/reliability_diagram.rst b/docs/Users_Guide/reliability_diagram.rst index 4f15663ae..ee0865d86 100644 --- a/docs/Users_Guide/reliability_diagram.rst +++ b/docs/Users_Guide/reliability_diagram.rst @@ -160,7 +160,7 @@ corresponding to the *plot_filename* setting in the default configuration file. Otherwise, this will need to be specified in *plot_filename* in the **minimal_box.yaml** file): -.. literalinclude:: ../../test/reliability_diagram/custom_reliability_use_defaults.yaml +.. literalinclude:: ../../test/reliability_diagram/minimal_reliability.yaml Copy this file to the working directory: diff --git a/docs/conf.py b/docs/conf.py index 1bb37e213..ffff38acd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,14 +22,14 @@ # -- Project information ----------------------------------------------------- project = 'METplotpy' -copyright = '2025, NSF NCAR' +copyright = '2026, NSF NCAR' author = 'UCAR/NSF NCAR, NOAA, CSU/CIRA, and CU/CIRES' author_list = 'Adriaansen, D., C. Kalb, D. Fillmore, T. Jensen, L. Goodrich, M. Win-Gildenmeister, T. Burek, and H. Fisher' verinfo = version release = f'{version}' -release_year = '2025' +release_year = '2026' -release_date = f'{release_year}-09-30' +release_date = f'{release_year}-02-05' copyright = f'{release_year}, {author}' diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 77f3dab5f..771ffbf41 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -23,11 +23,11 @@ from plotly.subplots import make_subplots import metcalcpy.util.utils as calc_util -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.bar.bar_config import BarConfig from metplotpy.plots.bar.bar_series import BarSeries -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 5a6436ac2..091d12143 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/bar/bar_series.py b/metplotpy/plots/bar/bar_series.py index f455bd154..aa73bd238 100644 --- a/metplotpy/plots/bar/bar_series.py +++ b/metplotpy/plots/bar/bar_series.py @@ -20,7 +20,7 @@ from pandas import DataFrame import metcalcpy.util.utils as utils -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from .. import GROUP_SEPARATOR from ..series import Series diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 6925ce646..7d86bfd35 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -1,14 +1,13 @@ # ============================* - # ** Copyright UCAR (c) 2020 + # ** Copyright UCAR (c) 2026 # ** University Corporation for Atmospheric Research (UCAR) - # ** National Center for Atmospheric Research (NCAR) + # ** National Science Foundation National Center for Atmospheric Research (NSF NCAR) # ** Research Applications Lab (RAL) # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA # ============================* -# !/usr/bin/env conda run -n blenny_363 python """ Class Name: base_plot.py """ @@ -18,29 +17,15 @@ import logging import warnings import numpy as np +from matplotlib.font_manager import FontProperties import yaml from typing import Union -import kaleido -import metplotpy.plots.util from metplotpy.plots.util import strtobool from .config import Config -from metplotpy.plots.context_filter import ContextFilter - -# kaleido 0.x will be deprecated after September 2025 and Chrome will no longer -# be included with kaleido from version 1.0.0. Explicitly get Chrome via call to kaleido. - -# In some instances, we do NOT want Chrome to be installed at run-time. If the -# PRE_LOAD_CHROME environment variable exists, or set to TRUE, -# then Chrome will be assumed to have been pre-loaded. Otherwise, -# invoke get_chrome_sync() to install Chrome in the -# /path-to-python-libs/pythonx.yz/site-packages/... directory - -# Check if the PRE_LOAD_CHROME env variable exists -aquire_chrome = False turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime -if turn_on_logging is True: +if turn_on_logging: log = logging.getLogger("base_plot") log.setLevel(logging.INFO) @@ -49,24 +34,11 @@ # set the WRITE_LOG env var to True to save the log message to a # separate log file write_log = strtobool('WRITE_LOG') - if write_log is True: + if write_log: file_handler = logging.FileHandler("./base_plot.log") file_handler.setFormatter(formatter) log.addHandler(file_handler) -# Only load Chrome at run-time if PRE_LOAD_CHROME is False or not defined. -# Some applications may not want to load Chrome at runtime and -# will set the PRE_LOAD_CHROME to True to indicate that it is already -# loaded/downloaded prior to runtime. -chrome_env =strtobool ('PRE_LOAD_CHROME') -if chrome_env is False: - aquire_chrome=True - kaleido.get_chrome_sync() - - -# Log when kaleido is downloading Chrome -if aquire_chrome is True and turn_on_logging is True: - log.info("Plotly kaleido is loading Chrome at run time") class BasePlot: """A class that provides methods for building Plotly plot's common features @@ -139,31 +111,7 @@ def get_image_format(self): print('Unrecognised image format. png will be used') return self.DEFAULT_IMAGE_FORMAT - def get_legend(self): - """Creates a Plotly legend dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the legend - """ - current_legend = dict( - x=self.get_config_value('legend', 'x'), # x-position - y=self.get_config_value('legend', 'y'), # y-position - font=dict( - family=self.get_config_value('legend', 'font', 'family'), # font family - size=self.get_config_value('legend', 'font', 'size'), # font size - color=self.get_config_value('legend', 'font', 'color'), # font color - ), - bgcolor=self.get_config_value('legend', 'bgcolor'), # background color - bordercolor=self.get_config_value('legend', 'bordercolor'), # border color - borderwidth=self.get_config_value('legend', 'borderwidth'), # border width - xanchor=self.get_config_value('legend', 'xanchor'), # horizontal position anchor - yanchor=self.get_config_value('legend', 'yanchor') # vertical position anchor - ) - return current_legend def get_legend_style(self): """ @@ -177,6 +125,7 @@ def get_legend_style(self): are set in METviewer """ legend_box = self.get_config_value('legend_box').lower() + borderwidth = 0 if legend_box == 'o': # Draws a box around the legend borderwidth = 1 @@ -191,129 +140,69 @@ def get_legend_style(self): legend_orientation = "v" legend_inset = self.get_config_value('legend_inset') legend_size = self.get_config_value('legend_size') - legend_settings = dict(border_width=borderwidth, - orientation=legend_orientation, - legend_inset=dict(x=legend_inset['x'], - y=legend_inset['y']), - legend_size=legend_size) + legend_settings = { + "border_width": borderwidth, + "orientation": legend_orientation, + "legend_inset": { + 'x': legend_inset['x'], + 'y': legend_inset['y'], + }, + 'legend_size': legend_size, + } return legend_settings - def get_title(self): - """Creates a Plotly title dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the title + def get_weights_size_styles(self): """ - current_title = dict( - text=self.get_config_value('title'), # plot's title - # Sets the container `x` refers to. "container" spans the entire `width` of the plot. - # "paper" refers to the width of the plotting area only. - xref="paper", - x=0.5 # x position with respect to `xref` - ) - return current_title - - def get_xaxis(self): - """Creates a Plotly x-axis dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults + Set up the font properties for the plot title: style (regular, italic), size, and + weight (normal, bold) for the title, captions, x-axis label, and y-axis label. - Args: - - Returns: - - dictionary used by Plotly to build the x-axis + Returns: + weights_size_styles: A dictionary containing the font property information + for the title, captions, x-axis label, and y-axis label """ - current_xaxis = dict( - linecolor=self.get_config_value('xaxis', 'linecolor'), # x-axis line color - # whether or not a line bounding x-axis is drawn - showline=self.get_config_value('xaxis', 'showline'), - linewidth=self.get_config_value('xaxis', 'linewidth') # width (in px) of x-axis line - ) - return current_xaxis - - def get_yaxis(self): - """Creates a Plotly y-axis dictionary with values from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults + weights_size_styles = {} + + # For title + title_property= FontProperties() + title_property.set_size(self.config_obj.title_size) + style = self.config_obj.title_weight[0] + wt = self.config_obj.title_weight[1] + title_property.set_style(style) + title_property.set_weight(wt) + weights_size_styles['title'] = title_property + + # For caption + caption_property = FontProperties() + caption_property.set_size(self.config_obj.caption_size) + cap_style = self.config_obj.caption_weight[0] + cap_wt = self.config_obj.caption_weight[1] + caption_property.set_style(cap_style) + caption_property.set_weight(cap_wt) + weights_size_styles['caption'] = caption_property + + # For xaxis label + xlab_property= FontProperties() + + xlab_property.set_size(self.config_obj.x_title_font_size) + xlab_style = self.config_obj.xlab_weight[0] + xlab_wt = self.config_obj.xlab_weight[1] + xlab_property.set_style(xlab_style) + xlab_property.set_weight(xlab_wt) + weights_size_styles['xlab'] = xlab_property + + # For yaxis label + ylab_property = FontProperties() + ylab_property.set_size(self.config_obj.y_title_font_size) + ylab_style = self.config_obj.ylab_weight[0] + ylab_wt = self.config_obj.ylab_weight[1] + ylab_property.set_style(ylab_style) + ylab_property.set_weight(ylab_wt) + weights_size_styles['ylab'] = ylab_property + + return weights_size_styles - Args: - Returns: - - dictionary used by Plotly to build the y-axis - """ - current_yaxis = dict( - linecolor=self.get_config_value('yaxis', 'linecolor'), # y-axis line color - linewidth=self.get_config_value('yaxis', 'linewidth'), # width (in px) of y-axis line - # whether or not a line bounding y-axis is drawn - showline=self.get_config_value('yaxis', 'showline'), - # whether or not grid lines are drawn - showgrid=self.get_config_value('yaxis', 'showgrid'), - ticks=self.get_config_value('yaxis', 'ticks'), # whether ticks are drawn or not. - tickwidth=self.get_config_value('yaxis', 'tickwidth'), # Sets the tick width (in px). - tickcolor=self.get_config_value('yaxis', 'tickcolor'), # Sets the tick color. - # the width (in px) of the grid lines - gridwidth=self.get_config_value('yaxis', 'gridwidth'), - gridcolor=self.get_config_value('yaxis', 'gridcolor') # the color of the grid lines - ) - - # Sets the range of the range slider. defaults to the full y-axis range - y_range = self.get_config_value('yaxis', 'range') - if y_range is not None: - current_yaxis['range'] = y_range - return current_yaxis - - def get_xaxis_title(self): - """Creates a Plotly x-axis label title dictionary with values - from users and default parameters. - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the x-axis label title as annotation - """ - x_axis_label = dict( - x=self.get_config_value('xaxis', 'x'), # x-position of label - y=self.get_config_value('xaxis', 'y'), # y-position of label - showarrow=False, - text=self.get_config_value('xaxis', 'title', 'text'), - xref="paper", # the annotation's x coordinate axis - yref="paper", # the annotation's y coordinate axis - font=dict( - family=self.get_config_value('xaxis', 'title', 'font', 'family'), - size=self.get_config_value('xaxis', 'title', 'font', 'size'), - color=self.get_config_value('xaxis', 'title', 'font', 'color'), - ) - ) - return x_axis_label - - def get_yaxis_title(self): - """Creates a Plotly y-axis label title dictionary with values - from users and default parameters - If users parameters dictionary doesn't have needed values - use defaults - - Args: - - Returns: - - dictionary used by Plotly to build the y-axis label title as annotation - """ - y_axis_label = dict( - x=self.get_config_value('yaxis', 'x'), # x-position of label - y=self.get_config_value('yaxis', 'y'), # y-position of label - showarrow=False, - text=self.get_config_value('yaxis', 'title', 'text'), - textangle=-90, # the angle at which the `text` is drawn with respect to the horizontal - xref="paper", # the annotation's x coordinate axis - yref="paper", # the annotation's y coordinate axis - font=dict( - family=self.get_config_value('xaxis', 'title', 'font', 'family'), - size=self.get_config_value('xaxis', 'title', 'font', 'size'), - color=self.get_config_value('xaxis', 'title', 'font', 'color'), - ) - ) - return y_axis_label def get_config_value(self, *args): """Gets the value of a configuration parameter. @@ -414,69 +303,28 @@ def remove_file(self): if image_name is not None and os.path.exists(image_name): os.remove(image_name) - def show_in_browser(self): - """Creates a plot and opens it in the browser. - Args: + @staticmethod + def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + """Adds a horizontal line to the matplotlib plot - Returns: + @param plt: Matplotlib pyplot object + @param y y value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None + """ + plt.axhline(y=y, xmin=0, xmax=1, **line_properties) - """ - if self.figure: - self.figure.show() - else: - self.logger.error(" Figure not created. Nothing to show in the " - "browser. ") - print("Oops! The figure was not created. Can't show") + @staticmethod + def add_vertical_line(plt, x: float, line_properties: dict) -> None: + """Adds a vertical line to the matplotlib plot - def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = None) -> None: - """ Adds custom horizontal and/or vertical line to the plot. - All line's metadata is in the config_obj.lines - Args: - @config_obj - plot's configurations - @x_points_index - list of x-values that are used to create a plot - Returns: + @param plt: Matplotlib pyplot object + @param x x value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None """ - if hasattr(config_obj, 'lines') and config_obj.lines is not None: - shapes = [] - for line in config_obj.lines: - # draw horizontal line - if line['type'] == 'horiz_line': - shapes.append(dict( - type='line', - yref='y', y0=line['position'], y1=line['position'], - xref='paper', x0=0, x1=0.95, - line={'color': line['color'], - 'dash': line['line_style'], - 'width': line['line_width']}, - )) - elif line['type'] == 'vert_line': - # draw vertical line - try: - if x_points_index is None: - val = line['position'] - else: - ordered_indy_label = config_obj.create_list_by_plot_val_ordering(config_obj.indy_label) - index = ordered_indy_label.index(line['position']) - val = x_points_index[index] - shapes.append(dict( - type='line', - yref='paper', y0=0, y1=1, - xref='x', x0=val, x1=val, - line={'color': line['color'], - 'dash': line['line_style'], - 'width': line['line_width']}, - )) - except ValueError: - line_position = line["position"] - self.logger.warning(f" Vertical line with position " - f"{line_position} cannot be created.") - print(f'WARNING: vertical line with position ' - f'{line_position} can\'t be created') - # ignore everything else - - # draw lines - self.figure.update_layout(shapes=shapes) + plt.axvline(x=x, ymin=0, ymax=1, **line_properties) @staticmethod def get_array_dimensions(data): diff --git a/metplotpy/plots/base_plot_plotly.py b/metplotpy/plots/base_plot_plotly.py new file mode 100644 index 000000000..197720e98 --- /dev/null +++ b/metplotpy/plots/base_plot_plotly.py @@ -0,0 +1,494 @@ +# ============================* + # ** Copyright UCAR (c) 2020 + # ** University Corporation for Atmospheric Research (UCAR) + # ** National Center for Atmospheric Research (NCAR) + # ** Research Applications Lab (RAL) + # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA + # ============================* + + + +# !/usr/bin/env conda run -n blenny_363 python +""" +Class Name: base_plot.py + """ +__author__ = 'Tatiana Burek' + +import os +import logging +import warnings +import numpy as np +import yaml +from typing import Union +import kaleido + +from metplotpy.plots.util_plotly import strtobool +from .config import Config +from metplotpy.plots.context_filter import ContextFilter + +# kaleido 0.x will be deprecated after September 2025 and Chrome will no longer +# be included with kaleido from version 1.0.0. Explicitly get Chrome via call to kaleido. + +# In some instances, we do NOT want Chrome to be installed at run-time. If the +# PRE_LOAD_CHROME environment variable exists, or set to TRUE, +# then Chrome will be assumed to have been pre-loaded. Otherwise, +# invoke get_chrome_sync() to install Chrome in the +# /path-to-python-libs/pythonx.yz/site-packages/... directory + +# Check if the PRE_LOAD_CHROME env variable exists +aquire_chrome = False + +turn_on_logging = strtobool('LOG_BASE_PLOT') +# Log when Chrome is downloaded at runtime +if turn_on_logging is True: + log = logging.getLogger("base_plot") + log.setLevel(logging.INFO) + + formatter = logging.Formatter("%(asctime)s [%(levelname)s] | %(name)s | %(message)s") + + # set the WRITE_LOG env var to True to save the log message to a + # separate log file + write_log = strtobool('WRITE_LOG') + if write_log is True: + file_handler = logging.FileHandler("./base_plot.log") + file_handler.setFormatter(formatter) + log.addHandler(file_handler) + +# Only load Chrome at run-time if PRE_LOAD_CHROME is False or not defined. +# Some applications may not want to load Chrome at runtime and +# will set the PRE_LOAD_CHROME to True to indicate that it is already +# loaded/downloaded prior to runtime. +chrome_env =strtobool ('PRE_LOAD_CHROME') +if chrome_env is False: + aquire_chrome=True + kaleido.get_chrome_sync() + + +# Log when kaleido is downloading Chrome +if aquire_chrome is True and turn_on_logging is True: + log.info("Plotly kaleido is loading Chrome at run time") + +class BasePlot: + """A class that provides methods for building Plotly plot's common features + like title, axis, legend. + + To use: + use as an abstract class for the common plot types + """ + + # image formats supported by plotly + IMAGE_FORMATS = ("png", "jpeg", "webp", "svg", "pdf", "eps") + DEFAULT_IMAGE_FORMAT = 'png' + + def __init__(self, parameters, default_conf_filename): + """Inits BasePlot with user defined and default dictionaries. + Removes the old image if it exists + + Args: + @param parameters - dictionary containing user defined parameters + @param default_conf_filename - the name of the default config file + for the plot type that is a subclass. + + + """ + + # Determine location of the default YAML config files and then + # read defaults stored in YAML formatted file into the dictionary + if 'METPLOTPY_BASE' in os.environ: + location = os.path.join(os.environ['METPLOTPY_BASE'], 'metplotpy/plots/config') + else: + location = os.path.realpath(os.path.join(os.path.dirname(__file__), 'config')) + + with open(os.path.join(location, default_conf_filename), 'r') as stream: + try: + defaults = yaml.load(stream, Loader=yaml.FullLoader) + except yaml.YAMLError as exc: + print(exc) + + # merge user defined parameters into defaults if they exist + if parameters: + self.parameters = {**defaults, **parameters} + else: + self.parameters = defaults + + self.figure = None + self.remove_file() + self.config_obj = Config(self.parameters) + + def get_image_format(self): + """Reads the image format type from user provided image name. + Uses file extension as a type. If the file extension is not valid - + returns 'png' as a default + + Args: + + Returns: + - image format + """ + + # get image name from properties + image_name = self.get_config_value('image_name') + if image_name: + + # extract and validate the file extension + strings = image_name.split('.') + if strings and strings[-1] in self.IMAGE_FORMATS: + return strings[-1] + + # print the message if invalid and return default + print('Unrecognised image format. png will be used') + return self.DEFAULT_IMAGE_FORMAT + + def get_legend(self): + """Creates a Plotly legend dictionary with values from users and default parameters + If users parameters dictionary doesn't have needed values - use defaults + + Args: + + Returns: + - dictionary used by Plotly to build the legend + """ + + current_legend = dict( + x=self.get_config_value('legend', 'x'), # x-position + y=self.get_config_value('legend', 'y'), # y-position + font=dict( + family=self.get_config_value('legend', 'font', 'family'), # font family + size=self.get_config_value('legend', 'font', 'size'), # font size + color=self.get_config_value('legend', 'font', 'color'), # font color + ), + bgcolor=self.get_config_value('legend', 'bgcolor'), # background color + bordercolor=self.get_config_value('legend', 'bordercolor'), # border color + borderwidth=self.get_config_value('legend', 'borderwidth'), # border width + xanchor=self.get_config_value('legend', 'xanchor'), # horizontal position anchor + yanchor=self.get_config_value('legend', 'yanchor') # vertical position anchor + ) + return current_legend + + def get_legend_style(self): + """ + Retrieve the legend style settings that are set + in the METviewer tool + + Args: + + Returns: + - a dictionary that holds the legend settings that + are set in METviewer + """ + legend_box = self.get_config_value('legend_box').lower() + if legend_box == 'o': + # Draws a box around the legend + borderwidth = 1 + elif legend_box == 'n': + # Do not draw border around the legend labels. + borderwidth = 0 + + legend_ncol = self.get_config_value('legend_ncol') + if legend_ncol > 1: + legend_orientation = "h" + else: + legend_orientation = "v" + legend_inset = self.get_config_value('legend_inset') + legend_size = self.get_config_value('legend_size') + legend_settings = dict(border_width=borderwidth, + orientation=legend_orientation, + legend_inset=dict(x=legend_inset['x'], + y=legend_inset['y']), + legend_size=legend_size) + + return legend_settings + + def get_title(self): + """Creates a Plotly title dictionary with values from users and default parameters + If users parameters dictionary doesn't have needed values - use defaults + + Args: + + Returns: + - dictionary used by Plotly to build the title + """ + current_title = dict( + text=self.get_config_value('title'), # plot's title + # Sets the container `x` refers to. "container" spans the entire `width` of the plot. + # "paper" refers to the width of the plotting area only. + xref="paper", + x=0.5 # x position with respect to `xref` + ) + return current_title + + def get_xaxis(self): + """Creates a Plotly x-axis dictionary with values from users and default parameters + If users parameters dictionary doesn't have needed values - use defaults + + Args: + + Returns: + - dictionary used by Plotly to build the x-axis + """ + current_xaxis = dict( + linecolor=self.get_config_value('xaxis', 'linecolor'), # x-axis line color + # whether or not a line bounding x-axis is drawn + showline=self.get_config_value('xaxis', 'showline'), + linewidth=self.get_config_value('xaxis', 'linewidth') # width (in px) of x-axis line + ) + return current_xaxis + + def get_yaxis(self): + """Creates a Plotly y-axis dictionary with values from users and default parameters + If users parameters dictionary doesn't have needed values - use defaults + + Args: + + Returns: + - dictionary used by Plotly to build the y-axis + """ + current_yaxis = dict( + linecolor=self.get_config_value('yaxis', 'linecolor'), # y-axis line color + linewidth=self.get_config_value('yaxis', 'linewidth'), # width (in px) of y-axis line + # whether or not a line bounding y-axis is drawn + showline=self.get_config_value('yaxis', 'showline'), + # whether or not grid lines are drawn + showgrid=self.get_config_value('yaxis', 'showgrid'), + ticks=self.get_config_value('yaxis', 'ticks'), # whether ticks are drawn or not. + tickwidth=self.get_config_value('yaxis', 'tickwidth'), # Sets the tick width (in px). + tickcolor=self.get_config_value('yaxis', 'tickcolor'), # Sets the tick color. + # the width (in px) of the grid lines + gridwidth=self.get_config_value('yaxis', 'gridwidth'), + gridcolor=self.get_config_value('yaxis', 'gridcolor') # the color of the grid lines + ) + + # Sets the range of the range slider. defaults to the full y-axis range + y_range = self.get_config_value('yaxis', 'range') + if y_range is not None: + current_yaxis['range'] = y_range + return current_yaxis + + def get_xaxis_title(self): + """Creates a Plotly x-axis label title dictionary with values + from users and default parameters. + If users parameters dictionary doesn't have needed values - use defaults + + Args: + + Returns: + - dictionary used by Plotly to build the x-axis label title as annotation + """ + x_axis_label = dict( + x=self.get_config_value('xaxis', 'x'), # x-position of label + y=self.get_config_value('xaxis', 'y'), # y-position of label + showarrow=False, + text=self.get_config_value('xaxis', 'title', 'text'), + xref="paper", # the annotation's x coordinate axis + yref="paper", # the annotation's y coordinate axis + font=dict( + family=self.get_config_value('xaxis', 'title', 'font', 'family'), + size=self.get_config_value('xaxis', 'title', 'font', 'size'), + color=self.get_config_value('xaxis', 'title', 'font', 'color'), + ) + ) + return x_axis_label + + def get_yaxis_title(self): + """Creates a Plotly y-axis label title dictionary with values + from users and default parameters + If users parameters dictionary doesn't have needed values - use defaults + + Args: + + Returns: + - dictionary used by Plotly to build the y-axis label title as annotation + """ + y_axis_label = dict( + x=self.get_config_value('yaxis', 'x'), # x-position of label + y=self.get_config_value('yaxis', 'y'), # y-position of label + showarrow=False, + text=self.get_config_value('yaxis', 'title', 'text'), + textangle=-90, # the angle at which the `text` is drawn with respect to the horizontal + xref="paper", # the annotation's x coordinate axis + yref="paper", # the annotation's y coordinate axis + font=dict( + family=self.get_config_value('xaxis', 'title', 'font', 'family'), + size=self.get_config_value('xaxis', 'title', 'font', 'size'), + color=self.get_config_value('xaxis', 'title', 'font', 'color'), + ) + ) + return y_axis_label + + def get_config_value(self, *args): + """Gets the value of a configuration parameter. + Looks for parameter in the user parameter dictionary + + Args: + @ param args - chain of keys that defines a key to the parameter + + Returns: + - a value for the parameter of None + """ + + return self._get_nested(self.parameters, args) + + def _get_nested(self, data, args): + """Recursive function that uses the tuple with keys to find a value + in multidimensional dictionary. + + Args: + @data - dictionary for the lookup + @args - a tuple with keys + + Returns: + - a value for the parameter of None + """ + + if args and data: + + # get value for the first key + element = args[0] + if element: + value = data.get(element) + + # if the size of key tuple is 1 - the search is over + if len(args) == 1: + return value + + # if the size of key tuple is > 1 - search using other keys + return self._get_nested(value, args[1:]) + return None + + def get_img_bytes(self): + """Returns an image as a bytes object in a format specified in the config file + + Args: + + Returns: + - an image as a bytes object + """ + if self.figure: + return self.figure.to_image(format=self.get_config_value('image_format'), + width=self.get_config_value('width'), + height=self.get_config_value('height'), + scale=self.get_config_value('scale')) + + return None + + def save_to_file(self): + """Saves the image to a file specified in the config file. + Prints a message if fails + + Args: + + Returns: + + """ + image_name = self.get_config_value('plot_filename') + + # Suppress deprecation warnings from third-party packages that are not in our control. + warnings.filterwarnings("ignore", category=DeprecationWarning) + + # Create the directory for the output plot if it doesn't already exist + dirname = os.path.dirname(os.path.abspath(image_name)) + os.makedirs(dirname, exist_ok=True) + if self.figure: + try: + self.figure.write_image(image_name) + except FileNotFoundError: + self.logger.error(f"FileNotFoundError: Cannot save to file" + f" {image_name}") + # print("Can't save to file " + image_name) + except ResourceWarning: + self.logger.warning(f"ResourceWarning: in _kaleido" + f" {image_name}") + + except ValueError as ex: + self.logger.error(f"ValueError: Could not save output file.") + else: + self.logger.error(f"The figure {dirname} cannot be saved.") + print("Oops! The figure was not created. Can't save.") + + def remove_file(self): + """Removes previously made image file . + """ + image_name = self.get_config_value('plot_filename') + + # remove the old file if it exist + if image_name is not None and os.path.exists(image_name): + os.remove(image_name) + + def show_in_browser(self): + """Creates a plot and opens it in the browser. + + Args: + + Returns: + + """ + if self.figure: + self.figure.show() + else: + self.logger.error(" Figure not created. Nothing to show in the " + "browser. ") + print("Oops! The figure was not created. Can't show") + + def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = None) -> None: + """ Adds custom horizontal and/or vertical line to the plot. + All line's metadata is in the config_obj.lines + Args: + @config_obj - plot's configurations + @x_points_index - list of x-values that are used to create a plot + Returns: + """ + if hasattr(config_obj, 'lines') and config_obj.lines is not None: + shapes = [] + for line in config_obj.lines: + # draw horizontal line + if line['type'] == 'horiz_line': + shapes.append(dict( + type='line', + yref='y', y0=line['position'], y1=line['position'], + xref='paper', x0=0, x1=0.95, + line={'color': line['color'], + 'dash': line['line_style'], + 'width': line['line_width']}, + )) + elif line['type'] == 'vert_line': + # draw vertical line + try: + if x_points_index is None: + val = line['position'] + else: + ordered_indy_label = config_obj.create_list_by_plot_val_ordering(config_obj.indy_label) + index = ordered_indy_label.index(line['position']) + val = x_points_index[index] + shapes.append(dict( + type='line', + yref='paper', y0=0, y1=1, + xref='x', x0=val, x1=val, + line={'color': line['color'], + 'dash': line['line_style'], + 'width': line['line_width']}, + )) + except ValueError: + line_position = line["position"] + self.logger.warning(f" Vertical line with position " + f"{line_position} cannot be created.") + print(f'WARNING: vertical line with position ' + f'{line_position} can\'t be created') + # ignore everything else + + # draw lines + self.figure.update_layout(shapes=shapes) + + @staticmethod + def get_array_dimensions(data): + """Returns the dimension of the array + + Args: + @param data - input array + Returns: + - an integer representing the array's dimension or None + """ + if data is None: + return None + + np_array = np.array(data) + return len(np_array.shape) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 35e9f522f..4747f141f 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -28,11 +28,11 @@ import metcalcpy.util.utils as calc_util -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.box.box_config import BoxConfig from metplotpy.plots.box.box_series import BoxSeries -from metplotpy.plots import util -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots import util_plotly as util +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR class Box(BasePlot): diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index 410fc8c39..a576ef65a 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/box/box_series.py b/metplotpy/plots/box/box_series.py index 9546be9ac..a0434f3a3 100644 --- a/metplotpy/plots/box/box_series.py +++ b/metplotpy/plots/box/box_series.py @@ -22,7 +22,7 @@ from pandas import DataFrame import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from ..series import Series @@ -215,7 +215,7 @@ def _calculate_derived_values(self, log_level = self.config.log_level log_filename = self.config.log_filename - logger = metplotpy.plots.util.get_common_logger(log_level, log_filename) + logger = util.get_common_logger(log_level, log_filename) logger.info(f"Start calculating derived values: " diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 88d02d4cc..a1dec79fb 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -54,12 +54,9 @@ def __init__(self, parameters): self.indy_var = self.get_config_value('indy_var') self.show_plot_in_browser = self.get_config_value('show_plot_in_browser') - # Plot figure dimensions can be in either inches or pixels - pixels = self.get_config_value('plot_units') - plot_width = self.get_config_value('plot_width') - self.plot_width = self.calculate_plot_dimension(plot_width, pixels) - plot_height = self.get_config_value('plot_height') - self.plot_height = self.calculate_plot_dimension(plot_height, pixels) + # Plot figure dimensions should be in inches + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height' ) self.plot_caption = self.get_config_value('plot_caption') # plain text, bold, italic, bold italic are choices in METviewer UI self.caption_weight = self.get_config_value('caption_weight') @@ -108,16 +105,17 @@ def __init__(self, parameters): self.plot_margins = self.get_config_value('mar') self.grid_on = self._get_bool('grid_on') if self.get_config_value('mar_offset'): - self.plot_margins = dict(l=0, - r=self.parameters['mar'][3] + 20, - t=self.parameters['mar'][2] + 80, - b=self.parameters['mar'][0] + 80, - pad=5 - ) + self.plot_margins = { + 'l': 0, + 'r': self.parameters['mar'][3] + 20, + 't': self.parameters['mar'][2] + 80, + 'b': self.parameters['mar'][0] + 80, + 'pad': 5, + } self.grid_col = self.get_config_value('grid_col') if self.grid_col: - self.blended_grid_col = metplotpy.plots.util.alpha_blending(self.grid_col, 0.5) + self.blended_grid_col = metplotpy.plots.util.alpha_blending(self.grid_col, 0.5) self.show_nstats = self._get_bool('show_nstats') self.indy_stagger = self._get_bool('indy_stagger') @@ -325,13 +323,15 @@ def _get_legend_style(self) -> dict: legend_bbox_x = legend_inset['x'] legend_bbox_y = legend_inset['y'] legend_size = self.get_config_value('legend_size') - legend_settings = dict(bbox_x=legend_bbox_x, - bbox_y=legend_bbox_y, - legend_size=legend_size, - legend_ncol=legend_ncol, - legend_box=legend_box) + legend_settings = { + 'bbox_x': legend_bbox_x, + 'bbox_y': legend_bbox_y, + 'legend_size': legend_size, + 'legend_ncol': legend_ncol, + 'legend_box': legend_box, + } else: - legend_settings = dict() + legend_settings = {} return legend_settings @@ -446,7 +446,7 @@ def calculate_number_of_series(self) -> int: # Utilize itertools' product() to create the cartesian product of all elements # in the lists to produce all permutations of the series_val values and the # fcst_var_val values. - permutations = [p for p in itertools.product(*series_vals_list)] + permutations = list(itertools.product(*series_vals_list)) return len(permutations) @@ -560,6 +560,16 @@ def _get_user_legends(self, legend_label_type: str ) -> list: Retrieve the text that is to be displayed in the legend at the bottom of the plot. Each entry corresponds to a series. + For legend labels that aren't set (ie in conf file they are set to '') + create a legend label based on the permutation of the series names + appended by 'user_legend label'. For example, for: + series_val_1: + model: + - NoahMPv3.5.1_d01 + vx_mask: + - CONUS + The constructed legend label will be "NoahMPv3.5.1_d01 CONUS Performance" + Args: @parm legend_label_type: The legend label, such as 'Performance', used when the user hasn't indicated a legend in the @@ -569,41 +579,7 @@ def _get_user_legends(self, legend_label_type: str ) -> list: a list consisting of the series label to be displayed in the plot legend. """ - all_legends = self.get_config_value('user_legend') - - # for legend labels that aren't set (ie in conf file they are set to '') - # create a legend label based on the permutation of the series names - # appended by 'user_legend label'. For example, for: - # series_val_1: - # model: - # - NoahMPv3.5.1_d01 - # vx_mask: - # - CONUS - # The constructed legend label will be "NoahMPv3.5.1_d01 CONUS Performance" - - - # Check for empty list as setting in the config file - legends_list = [] - - # set a flag indicating when a legend label is specified - legend_label_unspecified = True - - # Check if a stat curve was requested, if so, then the number - # of series_val_1 values will be inconsistent with the number of - # legend labels 'specified' (either with actual labels or whitespace) - - num_series = self.calculate_number_of_series() - if len(all_legends) == 0: - for i in range(num_series): - legends_list.append(' ') - else: - for legend in all_legends: - if len(legend) == 0: - legend = ' ' - legends_list.append(legend) - else: - legend_label_unspecified = False - legends_list.append(legend) + legends_list, legend_label_unspecified = self._get_legends_list() ll_list = [] series_list = self.all_series_vals @@ -615,8 +591,7 @@ def _get_user_legends(self, legend_label_type: str ) -> list: # check if summary_curve is present if 'summary_curve' in self.parameters.keys() and self.parameters['summary_curve'] != 'none': return [legend_label_type, self.parameters['summary_curve'] + ' ' + legend_label_type] - else: - return [legend_label_type] + return [legend_label_type] perms = utils.create_permutations(series_list) for idx,ll in enumerate(legends_list): @@ -635,6 +610,35 @@ def _get_user_legends(self, legend_label_type: str ) -> list: legends_list_ordered = self.create_list_by_series_ordering(ll_list) return legends_list_ordered + def _get_legends_list(self): + all_legends = self.get_config_value('user_legend') + + # Check for empty list as setting in the config file + legends_list = [] + + # set a flag indicating when a legend label is specified + legend_label_unspecified = True + + # Check if a stat curve was requested, if so, then the number + # of series_val_1 values will be inconsistent with the number of + # legend labels 'specified' (either with actual labels or whitespace) + + num_series = self.calculate_number_of_series() + if len(all_legends) == 0: + for _ in range(num_series): + legends_list.append(' ') + else: + for legend in all_legends: + if len(legend) == 0: + legend = ' ' + legends_list.append(legend) + else: + legend_label_unspecified = False + legends_list.append(legend) + + return legends_list, legend_label_unspecified + + def _get_plot_resolution(self) -> int: """ Retrieve the plot_res and plot_unit to determine the dpi @@ -656,17 +660,7 @@ def _get_plot_resolution(self) -> int: # check if the units value has been set in the config file if self.get_config_value('plot_units'): - units = self.get_config_value('plot_units').lower() - if units == 'in': - return resolution - - if units == 'mm': - # convert mm to inches so we can - # set dpi value - return resolution * constants.MM_TO_INCHES - - # units not supported, assume inches - return resolution + return self._convert_units_to_inches(resolution, self.get_config_value('plot_units')) # units not indicated, assume # we are dealing with inches @@ -676,6 +670,19 @@ def _get_plot_resolution(self) -> int: # dpi used by matplotlib return dpi + def _convert_units_to_inches(self, value, units): + units_lower = units.lower() + if units_lower == 'mm': + return value * constants.MM_TO_INCHES + if units_lower == 'cm': + return value * constants.CM_TO_INCHES + + # if unsupported units are specified, log a warning but assume inches + if units_lower != 'in': + self.logger.warning(f"Invalid units specified: {units}. Expected in, mm, or cm. Assuming inches.") + + return value + def create_list_by_series_ordering(self, setting_to_order) -> list: """ Generate a list of series plotting settings based on what is set @@ -773,11 +780,10 @@ def create_list_by_plot_val_ordering(self, setting_to_order: str) -> list: return ordered_settings_list - def calculate_plot_dimension(self, config_value: str , output_units: str) -> int: + def calculate_plot_dimension(self, config_value: str) -> int: ''' To calculate the width or height that defines the size of the plot. - Matplotlib defines these values in inches, Python plotly defines these - in terms of pixels. METviewer accepts units of inches or mm for width and + Matplotlib defines these values in inches. METviewer accepts units of inches or mm for width and height, so conversion from mm to inches or mm to pixels is necessary, depending on the requested output units, output_units. @@ -785,43 +791,18 @@ def calculate_plot_dimension(self, config_value: str , output_units: str) -> int @param config_value: The plot dimension to convert, either a width or height, in inches or mm @param output_units: pixels or in (inches) to indicate which - units to use to define plot size. Python plotly uses pixels and - Matplotlib uses inches. + units to use to define plot size. Matplotlib uses inches. Returns: converted_value : converted value from in/mm to pixels or mm to inches based on input values ''' - + value2convert = self.get_config_value(config_value) - resolution = self.get_config_value('plot_res') units = self.get_config_value('plot_units') - # initialize converted_value to some small value - converted_value = 0 - - # convert to pixels - # plotly uses pixels for setting plot size (width and height) - if output_units.lower() == 'pixels': - if units.lower() == 'in': - # value in pixels - converted_value = int(resolution * value2convert) - elif units.lower() == 'mm': - # Convert mm to pixels - converted_value = int(resolution * value2convert * constants.MM_TO_INCHES) - # Matplotlib uses inches (in) for setting plot size (width and height) - elif output_units.lower() == 'in': - if units.lower() == 'mm': - # Convert mm to inches - converted_value = value2convert * constants.MM_TO_INCHES - else: - converted_value = value2convert + return self._convert_units_to_inches(value2convert, units) - # plotly does not allow any value smaller than 10 pixels - if output_units.lower() == 'pixels' and converted_value < 10: - converted_value = 10 - - return converted_value def _get_bool(self, param: str) -> Union[bool, None]: """ @@ -857,41 +838,35 @@ def _get_lines(self) -> Union[list, None]: # get property value from the parameters lines = self.get_config_value('lines') + if lines is None: + return None # if the property exists - proceed - if lines is not None: - # validate data and replace the values - for line in lines: - - # validate line_type - line_type = line['type'] - if line_type not in ('horiz_line', 'vert_line') : - print(f'WARNING: custom line type {line["type"]} is not supported') + # validate data and replace the values + for line in lines: + + # validate line_type + if line['type'] not in ('horiz_line', 'vert_line') : + print(f'WARNING: custom line type {line["type"]} is not supported') + line['type'] = None + continue + + # convert position to float if line_type=horiz_line + if line['type'] == 'horiz_line': + try: + line['position'] = float(line['position']) + except ValueError: + print(f'WARNING: custom line position {line["position"]} is invalid') line['type'] = None - else: - # convert position to float if line_type=horiz_line - if line['type'] == 'horiz_line': - try: - line['position'] = float(line['position']) - except ValueError: - print(f'WARNING: custom line position {line["position"]} is invalid') - line['type'] = None - else: - # convert position to string if line_type=vert_line - line['position'] = str(line['position']) - - # convert line_style - line_style = line['line_style'] - if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): - line['line_style'] = constants.LINE_STYLE_TO_PLOTLY_DASH[line_style] - else: - line['line_style'] = None - - # convert line_width to float - try: - line['line_width'] = float(line['line_width']) - except ValueError: - print(f'WARNING: custom line width {line["line_width"]} is invalid') - line['type'] = None + else: + # convert position to string if line_type=vert_line + line['position'] = str(line['position']) + + # convert line_width to float + try: + line['line_width'] = float(line['line_width']) + except ValueError: + print(f'WARNING: custom line width {line["line_width"]} is invalid') + line['type'] = None return lines diff --git a/metplotpy/plots/config_plotly.py b/metplotpy/plots/config_plotly.py new file mode 100644 index 000000000..874d20be3 --- /dev/null +++ b/metplotpy/plots/config_plotly.py @@ -0,0 +1,897 @@ +# ============================* + # ** Copyright UCAR (c) 2020 + # ** University Corporation for Atmospheric Research (UCAR) + # ** National Center for Atmospheric Research (NCAR) + # ** Research Applications Lab (RAL) + # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA + # ============================* + + + +""" +Class Name: config.py + +Holds values set in the config file(s) +""" +__author__ = 'Minna Win' + +import itertools +from typing import Union + +import metcalcpy.util.utils as utils +import metplotpy.plots.util_plotly as util +from . import constants_plotly as constants + + +class Config: + """ + Handles reading in and organizing configuration settings in the yaml configuration file. + """ + + def __init__(self, parameters): + + self.parameters = parameters + + # Logging + self.log_filename = self.get_config_value('log_filename') + self.log_level = self.get_config_value('log_level') + self.logger = util.get_common_logger(self.log_level, self.log_filename) + + # + # Configuration settings that apply to the plot + # + self.output_image = self.get_config_value('plot_filename') + self.title_font = constants.DEFAULT_TITLE_FONT + self.title_color = constants.DEFAULT_TITLE_COLOR + self.xaxis = self.get_config_value('xaxis') + self.yaxis_1 = self.get_config_value('yaxis_1') + self.yaxis_2 = self.get_config_value('yaxis_2') + self.title = self.get_config_value('title') + self.use_ee = self._get_bool('event_equal') + self.indy_vals = self.get_config_value('indy_vals') + self.indy_label = self._get_indy_label() + self.indy_var = self.get_config_value('indy_var') + self.show_plot_in_browser = self.get_config_value('show_plot_in_browser') + + # Plot figure dimensions can be in either inches or pixels + pixels = self.get_config_value('plot_units') + plot_width = self.get_config_value('plot_width') + self.plot_width = self.calculate_plot_dimension(plot_width, pixels) + plot_height = self.get_config_value('plot_height') + self.plot_height = self.calculate_plot_dimension(plot_height, pixels) + self.plot_caption = self.get_config_value('plot_caption') + # plain text, bold, italic, bold italic are choices in METviewer UI + self.caption_weight = self.get_config_value('caption_weight') + self.caption_color = self.get_config_value('caption_col') + # relative magnification + self.caption_size = self.get_config_value('caption_size') + + # up-down location relative to the x-axis line + self.caption_offset = self.get_config_value('caption_offset') + # left-right position + self.caption_align = self.get_config_value('caption_align') + + # legend style settings as defined in METviewer + user_settings = self._get_legend_style() + + # list of the x, y + # bbox_to_anchor() setting used in determining + # the location of the bounding box which defines + # the legend. + + bbox_x = user_settings.get('bbox_x') + if bbox_x is not None: + self.bbox_x = float(user_settings['bbox_x']) + + bbox_y = user_settings.get('bbox_y') + if bbox_y is not None: + self.bbox_y = float(user_settings['bbox_y']) + + legend_magnification = user_settings.get('legend_size') + if legend_magnification is not None: + self.legend_size = int(constants.DEFAULT_LEGEND_FONTSIZE * legend_magnification) + + self.legend_ncol = self.get_config_value('legend_ncol') + legend_box = self.get_config_value('legend_box') + self.draw_box = False + if legend_box is not None: + legend_box = legend_box.lower() + if legend_box == 'o': + # Don't draw a box around legend labels + self.draw_box = True + + + # some settings used by some but not all plot types + + # Plotly plots often require offsets to the margins + self.plot_margins = self.get_config_value('mar') + self.grid_on = self._get_bool('grid_on') + if self.get_config_value('mar_offset'): + self.plot_margins = dict(l=0, + r=self.parameters['mar'][3] + 20, + t=self.parameters['mar'][2] + 80, + b=self.parameters['mar'][0] + 80, + pad=5 + ) + + self.grid_col = self.get_config_value('grid_col') + if self.grid_col: + self.blended_grid_col = util.alpha_blending(self.grid_col, 0.5) + self.show_nstats = self._get_bool('show_nstats') + self.indy_stagger = self._get_bool('indy_stagger') + + # Some of the plot types use Matplotlib, these settings are only relevant + # for plots implemented with Matplotlib. + + # left-right location of x-axis label/title relative to the y-axis line + # make adjustments between METviewer default and Matplotlib's center + # METviewer default value of 2 corresponds to Matplotlib value of .5 + # + mv_x_title_offset = self.get_config_value('xlab_offset') + if mv_x_title_offset: + self.x_title_offset = float(mv_x_title_offset) - 1.5 + + + # up-down of x-axis label/title position + # make adjustments between METviewer default and Matplotlib's center + # METviewer default is .5, Matplotlib center is 0.05, so subtract 0.55 from the + # METviewer setting to get the correct Matplotlib y-value (up/down) + # for the x-title position + mv_x_title_align = self.get_config_value('xlab_align') + if mv_x_title_align: + self.x_title_align = float(mv_x_title_align) - .55 + + # Need to use a combination of Matplotlib's font weight and font style to + + # re-create the METviewer xlab_weight. Use the + # MV_TO_MPL_CAPTION_STYLE dictionary to map these caption styles to + # what was requested in METviewer + mv_xlab_weight = self.get_config_value('xlab_weight') + self.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[mv_xlab_weight] + + self.x_tickangle = self.parameters['xtlab_orient'] + if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): + self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] + self.x_tickfont_size = self.parameters['xtlab_size'] * constants.MPL_FONT_SIZE_DEFAULT + + # y-axis labels and y-axis ticks + self.y_title_font_size = self.parameters['ylab_size'] * constants.DEFAULT_CAPTION_FONTSIZE + self.y_tickangle = self.parameters['ytlab_orient'] + if self.y_tickangle in constants.YAXIS_ORIENTATION.keys(): + self.y_tickangle = constants.YAXIS_ORIENTATION[self.y_tickangle] + self.y_tickfont_size = self.parameters['ytlab_size'] * constants.MPL_FONT_SIZE_DEFAULT + + # left-right position of y-axis label/title position + # make adjustments between METviewer default and Matplotlib's center + # METviewer default is .5, Matplotlib center is -0.05 + mv_y_title_align = self.get_config_value('ylab_align') + self.y_title_align = float(mv_y_title_align) - 0.55 + + # up-down location of y-axis label/title relative to the x-axis line + # make adjustments between METviewer default and Matplotlib's center + # METviewer default value of -2 corresponds to Matplotlib value of 0.4 + # + mv_y_title_offset = self.get_config_value('ylab_offset') + self.y_title_offset = float(mv_y_title_offset) + 2.4 + + # Need to use a combination of Matplotlib's font weight and font style to + # re-create the METviewer ylab_weight. Use the + # MV_TO_MPL_CAPTION_STYLE dictionary to map these caption styles to + # what was requested in METviewer + mv_ylab_weight = self.get_config_value('ylab_weight') + self.ylab_weight = constants.MV_TO_MPL_CAPTION_STYLE[mv_ylab_weight] + + # Adjust the caption left/right relative to the y-axis + # METviewer default is set to 0, corresponds to y=0.05 in Matplotlib + mv_caption_align = self.get_config_value('caption_align') + self.caption_align = float(mv_caption_align) + 0.13 + + # The plot's title size, title weight, and positioning in left-right and up-down directions + mv_title_size = self.get_config_value('title_size') + self.title_size = mv_title_size * constants.MPL_FONT_SIZE_DEFAULT + + mv_title_weight = self.get_config_value('title_weight') + # use the same constants dictionary as used for captions + self.title_weight = constants.MV_TO_MPL_CAPTION_STYLE[mv_title_weight] + + # These values can't be used as-is, the only choice for aligning in Matplotlib + # are center (default), left, and right + mv_title_align = self.get_config_value('title_align') + self.title_align = float(mv_title_align) + + # does nothing because the vertical position in Matplotlib is + # automatically chosen to avoid labels and ticks on the topmost + # x-axis + mv_title_offset = self.get_config_value('title_offset') + self.title_offset = float(mv_title_offset) + + # legend style settings as defined in METviewer + user_settings = self._get_legend_style() + + # list of the x, y, and loc values for the + # bbox_to_anchor() setting used in determining + + # the location of the bounding box which defines + # the legend. + # adjust METviewer values to be consistent with the Matplotlib scale + # The METviewer x default is set to 0, which corresponds to a Matplotlib + # x-value of 0.5 (roughly centered with respect to the x-axis) + mv_bbox_x = float(user_settings['bbox_x']) + self.bbox_x = mv_bbox_x + 0.5 + + # METviewer legend box y-value is set to -.25 by default, which corresponds + # to a Matplotlib y-value of -.1 + mv_bbox_y = float(user_settings['bbox_y']) + self.bbox_y = mv_bbox_y + .15 + legend_magnification = user_settings['legend_size'] + self.legend_size = int(constants.DEFAULT_LEGEND_FONTSIZE * legend_magnification) + self.legend_ncol = self.get_config_value('legend_ncol') + + # Don't draw a box around legend labels unless an 'o' is set + self.draw_box = False + legend_box = self.get_config_value('legend_box').lower() + + if legend_box == 'o': + self.draw_box = True + + # These are the inner keys to the series_val setting, and + # they represent the series variables of + # interest. The keys correspond to the column names + # in the input dataframe. + self.series_vals_1 = self._get_series_vals(1) + self.series_vals_2 = self._get_series_vals(2) + self.all_series_vals = self.series_vals_1.copy() + if self.series_vals_2: + self.all_series_vals.extend(self.series_vals_2) + + # Represent the names of the forecast variables (inner keys) to the fcst_var_val setting. + # These are the names of the columns in the input dataframe. + self.fcst_var_val_1 = self._get_fcst_vars(1) + self.fcst_var_val_2 = self._get_fcst_vars(2) + + # Get the list of the statistics of interest + self.list_stat_1 = self.get_config_value('list_stat_1') + self.list_stat_2 = self.get_config_value('list_stat_2') + + # These are the inner values to the series_val setting (these correspond to the + # keys returned in self.series_vals above). These are the specific variable values to + # be used in subsetting the input dataframe (e.g. for key='model', and value='SH_CMORPH', + # we want to subset data where column name is 'model', with coincident rows of 'SH_CMORPH'. + self.series_val_names = self._get_series_val_names() + self.series_ordering = None + self.indy_plot_val = self.get_config_value('indy_plot_val') + self.lines = self._get_lines() + + + def get_config_value(self, *args:Union[str,int,float]): + """Gets the value of a configuration parameter. + Looks for parameter in the user parameter dictionary + + Args: + @ param args - chain of keys that defines a key to the parameter + + Returns: + - a value for the parameter of None + """ + + return self._get_nested(self.parameters, args) + + def _get_nested(self, data:dict, args:tuple): + """Recursive function that uses the tuple with keys to find a value + in multidimensional dictionary. + + Args: + @data - dictionary for the lookup + @args - a tuple with keys + + Returns: + - a value for the parameter of None + """ + + if args and data: + + # get value for the first key + element = args[0] + if element: + value = data.get(element) + + # if the size of key tuple is 1 - the search is over + if len(args) == 1: + return value + + # if the size of key tuple is > 1 - search using other keys + return self._get_nested(value, args[1:]) + return None + + def _get_legend_style(self) -> dict: + """ + Retrieve the legend style settings that are set + in the METviewer tool + + Args: + + Returns: + - a dictionary that holds the legend settings that + are set in METviewer + """ + legend_box = self.get_config_value('legend_box') + if legend_box: + legend_box = legend_box.lower() + + legend_ncol = self.get_config_value('legend_ncol') + legend_inset = self.get_config_value('legend_inset') + if legend_inset: + legend_bbox_x = legend_inset['x'] + legend_bbox_y = legend_inset['y'] + legend_size = self.get_config_value('legend_size') + legend_settings = dict(bbox_x=legend_bbox_x, + bbox_y=legend_bbox_y, + legend_size=legend_size, + legend_ncol=legend_ncol, + legend_box=legend_box) + else: + legend_settings = dict() + + return legend_settings + + def _get_series_vals(self, index:int) -> list: + """ + Get a tuple of lists of all the variable values that correspond to the inner + key of the series_val dictionaries (series_val_1 and series_val_2). + These values will be used with lists of other config values to + create filtering criteria. This is useful to subset the input data + to assist in identifying the data points for this series. + + Args: + index: The number defining which of series_vals_1 or series_vals_2 to consider + + Returns: + lists of *all* the values of the inner dictionary + of the series_vals dictionaries + + """ + + if index == 1: + # evaluate series_val_1 setting + series_val_dict = self.get_config_value('series_val_1') + elif index == 2: + # evaluate series_val_2 setting + series_val_dict = self.get_config_value('series_val_2') + else: + raise ValueError('Index value must be either 1 or 2.') + + # check for empty setting. If so, return an empty list + if series_val_dict: + val_dict_list = [*series_val_dict.values()] + else: + val_dict_list = [] + + # Unpack and access the values corresponding to the inner keys + # (series_var1, series_var2, ..., series_varn). + return val_dict_list + + def _get_series_columns(self, index): + ''' Retrieve the column name that corresponds to this ''' + + def _get_fcst_vars(self, index: int) -> list: + """ + Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. + + Args: + index: identifier used to differentiate between fcst_var_val_1 and + fcst_var_val_2 config settings + Returns: + a list containing all the fcst variables requested in the + fcst_var_val setting in the config file. This will be + used to subset the input data that corresponds to a particular series. + + """ + if index == 1: + fcst_var_val_dict = self.get_config_value('fcst_var_val_1') + if fcst_var_val_dict: + all_fcst_vars = [*fcst_var_val_dict.keys()] + else: + all_fcst_vars = [] + elif index == 2: + fcst_var_val_dict = self.get_config_value('fcst_var_val_2') + if fcst_var_val_dict: + all_fcst_vars = [*fcst_var_val_dict.keys()] + else: + all_fcst_vars = [] + else: + all_fcst_vars = [] + + return all_fcst_vars + + def _get_series_val_names(self) -> list: + """ + Get a list of all the variable value names (i.e. inner key of the + series_val dictionary). These values will be used with lists of + other config values to create filtering criteria. This is useful + to subset the input data to assist in identifying the data points + for this series. + + Args: + + Returns: + a "list of lists" of *all* the keys to the inner dictionary of + the series_val dictionary + + """ + + series_val_dict = self.get_config_value('series_val_1') + + # Unpack and access the values corresponding to the inner keys + # (series_var1, series_var2, ..., series_varn). + if series_val_dict: + return [*series_val_dict.keys()] + return [] + + def calculate_number_of_series(self) -> int: + """ + From the number of items in the permutation list, + determine how many series "objects" are to be plotted. + + Args: + + Returns: + the number of series + + """ + + # Retrieve the lists from the series_val_1 dictionary + series_vals_list = self.series_vals_1 + + # Utilize itertools' product() to create the cartesian product of all elements + # in the lists to produce all permutations of the series_val values and the + # fcst_var_val values. + permutations = [p for p in itertools.product(*series_vals_list)] + + return len(permutations) + + def _get_colors(self) -> list: + """ + Retrieves the colors used for lines and markers, from the + config file (default or custom). + Args: + + Returns: + colors_list or colors_from_config: a list of the colors to be used for the lines + (and their corresponding marker symbols) + """ + + colors_settings = self.get_config_value('colors') + return self.create_list_by_series_ordering(list(colors_settings)) + + def _get_con_series(self) -> list: + """ + Retrieves the 'connect across NA' values used for lines and markers, from the + config file (default or custom). + Args: + + Returns: + con_series_list or con_series_from_config: a list of 1 and/or 0 to + be used for the lines + """ + con_series_settings = self.get_config_value('con_series') + return self.create_list_by_series_ordering(list(con_series_settings)) + + def _get_show_legend(self) -> list: + """ + Retrieves the 'show_legend' values used for displaying or + not the legend of a trace in the legend box, from the + config file. If 'show_legend' is not provided - throws an error + Args: + + Returns: + show_legend_list or show_legend_from_config: a list of 1 and/or 0 to + be used for the traces + """ + show_legend_settings = self.get_config_value('show_legend') + + # Support all variations of setting the show_legend: '1', 1, "true" (any combination of cases), True (boolean) + updated_show_legend_settings = [] + for legend_setting in show_legend_settings: + legend_setting = str(legend_setting).lower() + if legend_setting == '1' or legend_setting == 'true' or legend_setting == 1 or legend_setting is True: + updated_show_legend_settings.append(int(1)) + else: + updated_show_legend_settings.append(int(0)) + + + if show_legend_settings is None: + raise ValueError("ERROR: show_legend parameter is not provided.") + + return self.create_list_by_series_ordering(list(updated_show_legend_settings)) + + def _get_markers(self): + """ + Retrieve all the markers. + + Args: + + Returns: + markers: a list of the markers + """ + markers = self.get_config_value('series_symbols') + markers_list = [] + for marker in markers: + if marker in constants.AVAILABLE_MARKERS_LIST: + # markers is the matplotlib symbol: .,o, ^, d, H, or s + markers_list.append(marker) + else: + # markers are indicated by name: small circle, circle, triangle, + # diamond, hexagon, square + markers_list.append(constants.PCH_TO_MATPLOTLIB_MARKER[marker.lower()]) + markers_list_ordered = self.create_list_by_series_ordering(list(markers_list)) + return markers_list_ordered + + def _get_linewidths(self) -> Union[list, None]: + """ Retrieve all the linewidths from the configuration file, if not + specified in any config file, use the default values of 2 + + Args: + + Returns: + linewidth_list: a list of linewidths corresponding to each line (model) + """ + linewidths = self.get_config_value('series_line_width') + if linewidths is not None: + return self.create_list_by_series_ordering(list(linewidths)) + else: + return None + + def _get_linestyles(self) -> list: + """ + Retrieve all the linestyles from the config file. + + Args: + + Returns: + list of line styles, each line style corresponds to a particular series + """ + linestyles = self.get_config_value('series_line_style') + linestyle_list_ordered = self.create_list_by_series_ordering(list(linestyles)) + return linestyle_list_ordered + + + def _get_user_legends(self, legend_label_type: str ) -> list: + """ + Retrieve the text that is to be displayed in the legend at the bottom of the plot. + Each entry corresponds to a series. + + Args: + @parm legend_label_type: The legend label, such as 'Performance', + used when the user hasn't indicated a legend in the + configuration file. + + Returns: + a list consisting of the series label to be displayed in the plot legend. + + """ + all_legends = self.get_config_value('user_legend') + + # for legend labels that aren't set (ie in conf file they are set to '') + # create a legend label based on the permutation of the series names + # appended by 'user_legend label'. For example, for: + # series_val_1: + # model: + # - NoahMPv3.5.1_d01 + # vx_mask: + # - CONUS + # The constructed legend label will be "NoahMPv3.5.1_d01 CONUS Performance" + + + # Check for empty list as setting in the config file + legends_list = [] + + # set a flag indicating when a legend label is specified + legend_label_unspecified = True + + # Check if a stat curve was requested, if so, then the number + # of series_val_1 values will be inconsistent with the number of + # legend labels 'specified' (either with actual labels or whitespace) + + num_series = self.calculate_number_of_series() + if len(all_legends) == 0: + for i in range(num_series): + legends_list.append(' ') + else: + for legend in all_legends: + if len(legend) == 0: + legend = ' ' + legends_list.append(legend) + else: + legend_label_unspecified = False + legends_list.append(legend) + + ll_list = [] + series_list = self.all_series_vals + + # Some diagrams don't require a series_val1 value, hence + # resulting in a zero-sized series_list. In this case, + # the legend label will just be the legend_label_type. + if len(series_list) == 0 and legend_label_unspecified: + # check if summary_curve is present + if 'summary_curve' in self.parameters.keys() and self.parameters['summary_curve'] != 'none': + return [legend_label_type, self.parameters['summary_curve'] + ' ' + legend_label_type] + else: + return [legend_label_type] + + perms = utils.create_permutations(series_list) + for idx,ll in enumerate(legends_list): + if ll == ' ': + if len(series_list) > 1: + label_parts = [perms[idx][0], ' ', perms[idx][1], ' ', legend_label_type] + else: + label_parts = [perms[idx][0], ' ', legend_label_type] + legend_label = ''.join(label_parts) + ll_list.append(legend_label) + else: + ll_list.append(ll) + if 'summary_curve' in self.parameters.keys() and self.parameters['summary_curve'] != 'none': + ll_list.append(self.parameters['summary_curve'] + ' ' + legend_label_type) + + legends_list_ordered = self.create_list_by_series_ordering(ll_list) + return legends_list_ordered + + def _get_plot_resolution(self) -> int: + """ + Retrieve the plot_res and plot_unit to determine the dpi + setting in matplotlib. + + Args: + + Returns: + plot resolution in units of dpi (dots per inch) + + """ + # Initialize to the default resolution + # set by matplotlib + dpi = 100 + + # first check if plot_res is set in config file + if self.get_config_value('plot_res'): + resolution = self.get_config_value('plot_res') + + # check if the units value has been set in the config file + if self.get_config_value('plot_units'): + units = self.get_config_value('plot_units').lower() + if units == 'in': + return resolution + + if units == 'mm': + # convert mm to inches so we can + # set dpi value + return resolution * constants.MM_TO_INCHES + + # units not supported, assume inches + return resolution + + # units not indicated, assume + # we are dealing with inches + return resolution + + # no plot_res value is set, return the default + # dpi used by matplotlib + return dpi + + def create_list_by_series_ordering(self, setting_to_order) -> list: + """ + Generate a list of series plotting settings based on what is set + in series_order in the config file. + If the series_order is specified: + series_order: + -3 + -1 + -2 + + and color is set: + color: + -red + -blue + -green + + + Then the following is expected: + the first series' color is 'blue' + the second series' color is 'green' + the third series' color is 'red' + + This allows the user the flexibility to change marker symbols, colors, and + other line qualities between the series (lines) without having to re-order + *all* the values. + + Args: + + setting_to_order: the name of the setting (eg axis_line_width) to be + ordered based on the order indicated + in the config file under the series_order setting. + + Returns: + a list reflecting the order that is consistent with what was set in series_order + + """ + + # create a natural order if series_ordering is missing + if self.series_ordering is None: + self.series_ordering = list(range(1, len(setting_to_order) + 1)) + + # Make the series ordering list zero-based to sync with Python's zero-based counting + series_ordered_zb = [sorder - 1 for sorder in self.series_ordering] + + if len(setting_to_order) == len(series_ordered_zb): + # Reorder the settings according to the zero based series order. + settings_reordered = [setting_to_order[i] for i in series_ordered_zb] + return settings_reordered + + return setting_to_order + + + def create_list_by_plot_val_ordering(self, setting_to_order: str) -> list: + """ + Generate a list of indy parameters settings based on what is set + in indy_plot_val in the config file. + If the is specified: + -3 + -1 + -2 + + and indy_vals is set: + indy_vals: + -120000 + -150000 + -180000 + + Then the following is expected: + the first indy_val is 1850000 + the second indy_val is 120000 + the third indy_val is 150000 + + + Args: + + setting_to_order: the name of the setting (eg indy_vals) to be + ordered based on the order indicated + in the config file under the indy_plot_val setting. + + Returns: + a list reflecting the order that is consistent with what was set in indy_plot_val + """ + + # order the input list according to the series_order setting + ordered_settings_list = [] + # create a natural order if series_ordering is missing + if self.indy_plot_val is None or len(self.indy_plot_val) == 0: + self.indy_plot_val = list(range(1, len(setting_to_order) + 1)) + + # Make the series ordering list zero-based to sync with Python's zero-based counting + indy_ordered_zb = [sorder - 1 for sorder in self.indy_plot_val] + for idx, indy in enumerate(indy_ordered_zb): + ordered_settings_list.insert(indy, setting_to_order[idx]) + + return ordered_settings_list + + + def calculate_plot_dimension(self, config_value: str , output_units: str) -> int: + ''' + To calculate the width or height that defines the size of the plot. + Matplotlib defines these values in inches, Python plotly defines these + in terms of pixels. METviewer accepts units of inches or mm for width and + height, so conversion from mm to inches or mm to pixels is necessary, depending + on the requested output units, output_units. + + Args: + @param config_value: The plot dimension to convert, either a width or height, + in inches or mm + @param output_units: pixels or in (inches) to indicate which + units to use to define plot size. Python plotly uses pixels and + Matplotlib uses inches. + Returns: + converted_value : converted value from in/mm to pixels or mm to inches based + on input values + ''' + + value2convert = self.get_config_value(config_value) + resolution = self.get_config_value('plot_res') + units = self.get_config_value('plot_units') + + # initialize converted_value to some small value + converted_value = 0 + + # convert to pixels + # plotly uses pixels for setting plot size (width and height) + if output_units.lower() == 'pixels': + if units.lower() == 'in': + # value in pixels + converted_value = int(resolution * value2convert) + elif units.lower() == 'mm': + # Convert mm to pixels + converted_value = int(resolution * value2convert * constants.MM_TO_INCHES) + + # Matplotlib uses inches (in) for setting plot size (width and height) + elif output_units.lower() == 'in': + if units.lower() == 'mm': + # Convert mm to inches + converted_value = value2convert * constants.MM_TO_INCHES + else: + converted_value = value2convert + + # plotly does not allow any value smaller than 10 pixels + if output_units.lower() == 'pixels' and converted_value < 10: + converted_value = 10 + + return converted_value + + def _get_bool(self, param: str) -> Union[bool, None]: + """ + Validates the value of the parameter and returns a boolean + Args: + :param param: name of the parameter + Returns: + :return: boolean value or None + """ + + param_val = self.get_config_value(param) + if isinstance(param_val, bool): + return param_val + + if isinstance(param_val, str): + return param_val.upper() == 'TRUE' + + return None + + def _get_indy_label(self): + if 'indy_label' in self.parameters.keys(): + return self.get_config_value('indy_label') + return self.indy_vals + + def _get_lines(self) -> Union[list, None]: + """ + Initialises the custom lines properties and returns a validated list + Args: + + Returns: + :return: list of lines properties or None + """ + + # get property value from the parameters + lines = self.get_config_value('lines') + + # if the property exists - proceed + if lines is not None: + # validate data and replace the values + for line in lines: + + # validate line_type + line_type = line['type'] + if line_type not in ('horiz_line', 'vert_line') : + print(f'WARNING: custom line type {line["type"]} is not supported') + line['type'] = None + else: + # convert position to float if line_type=horiz_line + if line['type'] == 'horiz_line': + try: + line['position'] = float(line['position']) + except ValueError: + print(f'WARNING: custom line position {line["position"]} is invalid') + line['type'] = None + else: + # convert position to string if line_type=vert_line + line['position'] = str(line['position']) + + # convert line_style + line_style = line['line_style'] + if line_style in constants.LINE_STYLE_TO_PLOTLY_DASH.keys(): + line['line_style'] = constants.LINE_STYLE_TO_PLOTLY_DASH[line_style] + else: + line['line_style'] = None + + # convert line_width to float + try: + line['line_width'] = float(line['line_width']) + except ValueError: + print(f'WARNING: custom line width {line["line_width"]} is invalid') + line['type'] = None + + return lines diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 2c8fb35c6..8e822b53f 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -22,6 +22,7 @@ # used to convert plot units in mm to # inches, so we can pass in dpi to matplotlib MM_TO_INCHES = 0.03937008 +CM_TO_INCHES = MM_TO_INCHES * 0.1 # Available Matplotlib Line styles # ':' ... @@ -62,33 +63,24 @@ AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] -AVAILABLE_PLOTLY_MARKERS_LIST = ["circle-open", "circle", - "square", "diamond", - "hexagon", "triangle-up", "asterisk-open"] PCH_TO_MATPLOTLIB_MARKER = {'20': '.', '19': 'o', '17': '^', '1': 'H', '18': 'd', '15': 's', 'small circle': '.', 'circle': 'o', 'square': 's', 'triangle': '^', 'rhombus': 'd', 'ring': 'h'} -PCH_TO_PLOTLY_MARKER = {'0': 'circle-open', '19': 'circle', '20': 'circle', - '17': 'triangle-up', '15': 'square', '18': 'diamond', - '1': 'hexagon2', 'small circle': 'circle-open', - 'circle': 'circle', 'square': 'square', 'triangle': 'triangle-up', - 'rhombus': 'diamond', 'ring': 'hexagon2', '.': 'circle', - 'o': 'circle', '^': 'triangle-up', 'd': 'diamond', 'H': 'circle-open', - 'h': 'hexagon2', 's': 'square'} +# approximated from plotly marker size to matplotlib marker size +PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} -PCH_TO_PLOTLY_MARKER_SIZE = {'.': 5, 'o': 8, 's': 6, '^': 8, 'd': 6, 'H': 7} - -TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} -LINE_STYLE_TO_PLOTLY_DASH = {'-': None, '--': 'dash', ':': 'dot', '-:': 'dashdot'} XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -PLOTLY_PAPER_BGCOOR = "white" -PLOTLY_AXIS_LINE_COLOR = "#c2c2c2" -PLOTLY_AXIS_LINE_WIDTH = 2 +# approximated from plotly marker size to matplotlib marker size +PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} + +# used for tick angles +XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} +YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} # Caption weights supported in Matplotlib are normal, italic and oblique. # Map these onto the MetViewer requested values of 1 (normal), 2 (bold), diff --git a/metplotpy/plots/constants_plotly.py b/metplotpy/plots/constants_plotly.py new file mode 100644 index 000000000..2c8fb35c6 --- /dev/null +++ b/metplotpy/plots/constants_plotly.py @@ -0,0 +1,100 @@ +# ============================* + # ** Copyright UCAR (c) 2020 + # ** University Corporation for Atmospheric Research (UCAR) + # ** National Center for Atmospheric Research (NCAR) + # ** Research Applications Lab (RAL) + # ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA + # ============================* + + + +""" +Module Name: constants.py + +Mapping of constants used in plotting, as dictionaries. +METviewer values are keys, Matplotlib representations are the values. + +""" +__author__ = 'Minna Win' + +# CONVERSION FACTORS + +# used to convert plot units in mm to +# inches, so we can pass in dpi to matplotlib +MM_TO_INCHES = 0.03937008 + +# Available Matplotlib Line styles +# ':' ... +# '-.' _._. +# '--' ----- +# '-' ______ (solid line) +# ' ' no line + +# METviewer drop-down choices: +# p points (...) +# l lines (---, dashed line) +# o overplotted (_._ mix of dash and dots) +# b joined lines (____ solid line) +# s stairsteps (same as overplotted) +# h histogram like (no line style, this is unsupported) +# n none (no line style) + +# linestyles can be indicated by "long" name (points, lines, etc.) or +# by single letter designation ('p', 'n', etc) +LINESTYLE_BY_NAMES = {'solid': '-', 'points': ':', 'lines': '--', 'overplotted': '-.', + 'joined lines': '-', 'stairstep': '-.', + 'histogram': ' ', 'none': ' ', 'p': ':', + 'l': '--', 'o': '-.', 'b': '-', + 's': '-.', 'h': ' ', 'n': ' '} + +ACCEPTABLE_CI_VALS = ['NONE', 'BOOT', "STD", 'MET_PRM', 'MET_BOOT'] + +DEFAULT_TITLE_FONT = 'sans-serif' +DEFAULT_TITLE_COLOR = 'black' +DEFAULT_TITLE_FONTSIZE = 10 + +# Default size used in plotly legend text +DEFAULT_LEGEND_FONTSIZE = 12 +DEFAULT_CAPTION_FONTSIZE = 14 +DEFAULT_CAPTION_Y_OFFSET = -3.1 +DEFAULT_TITLE_FONT_SIZE = 11 +DEFAULT_TITLE_OFFSET = (-0.48) + + +AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] +AVAILABLE_PLOTLY_MARKERS_LIST = ["circle-open", "circle", + "square", "diamond", + "hexagon", "triangle-up", "asterisk-open"] + +PCH_TO_MATPLOTLIB_MARKER = {'20': '.', '19': 'o', '17': '^', '1': 'H', + '18': 'd', '15': 's', 'small circle': '.', + 'circle': 'o', 'square': 's', + 'triangle': '^', 'rhombus': 'd', 'ring': 'h'} + +PCH_TO_PLOTLY_MARKER = {'0': 'circle-open', '19': 'circle', '20': 'circle', + '17': 'triangle-up', '15': 'square', '18': 'diamond', + '1': 'hexagon2', 'small circle': 'circle-open', + 'circle': 'circle', 'square': 'square', 'triangle': 'triangle-up', + 'rhombus': 'diamond', 'ring': 'hexagon2', '.': 'circle', + 'o': 'circle', '^': 'triangle-up', 'd': 'diamond', 'H': 'circle-open', + 'h': 'hexagon2', 's': 'square'} + +PCH_TO_PLOTLY_MARKER_SIZE = {'.': 5, 'o': 8, 's': 6, '^': 8, 'd': 6, 'H': 7} + +TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} +LINE_STYLE_TO_PLOTLY_DASH = {'-': None, '--': 'dash', ':': 'dot', '-:': 'dashdot'} +XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} +YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} + +PLOTLY_PAPER_BGCOOR = "white" +PLOTLY_AXIS_LINE_COLOR = "#c2c2c2" +PLOTLY_AXIS_LINE_WIDTH = 2 + +# Caption weights supported in Matplotlib are normal, italic and oblique. +# Map these onto the MetViewer requested values of 1 (normal), 2 (bold), +# 3 (italic), 4 (bold italic), and 5 (symbol) using a dictionary +MV_TO_MPL_CAPTION_STYLE = {1:('normal', 'normal'), 2:('normal','bold'), 3:('italic', 'normal') + , 4:('italic', 'bold'),5:('oblique','normal')} + +# Matplotlib constants +MPL_FONT_SIZE_DEFAULT = 11 diff --git a/metplotpy/plots/contour/contour.py b/metplotpy/plots/contour/contour.py index 233b6e47e..0d400eba2 100644 --- a/metplotpy/plots/contour/contour.py +++ b/metplotpy/plots/contour/contour.py @@ -24,9 +24,9 @@ from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_PAPER_BGCOOR -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.constants_plotly import PLOTLY_PAPER_BGCOOR +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util from metplotpy.plots.contour.contour_config import ContourConfig from metplotpy.plots.contour.contour_series import ContourSeries from metplotpy.plots.series import Series diff --git a/metplotpy/plots/contour/contour_config.py b/metplotpy/plots/contour/contour_config.py index 6d28d4c76..4336c1e39 100644 --- a/metplotpy/plots/contour/contour_config.py +++ b/metplotpy/plots/contour/contour_config.py @@ -15,9 +15,9 @@ """ __author__ = 'Tatiana Burek' -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/contour/contour_series.py b/metplotpy/plots/contour/contour_series.py index 024bd3def..5f55acc69 100644 --- a/metplotpy/plots/contour/contour_series.py +++ b/metplotpy/plots/contour/contour_series.py @@ -18,7 +18,7 @@ import numpy as np import warnings -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from ..series import Series @@ -34,8 +34,7 @@ def __init__(self, config, idx: int, input_data, series_list: list, series_name: Union[list, tuple], y_axis: int = 1): self.series_list = series_list self.series_name = series_name - self.logger = metplotpy.plots.util.get_common_logger(config.log_level, - config.log_filename) + self.logger = util.get_common_logger(config.log_level, config.log_filename) super().__init__(config, idx, input_data, y_axis) diff --git a/metplotpy/plots/eclv/eclv.py b/metplotpy/plots/eclv/eclv.py index 358539d65..7e789decb 100644 --- a/metplotpy/plots/eclv/eclv.py +++ b/metplotpy/plots/eclv/eclv.py @@ -24,12 +24,12 @@ from datetime import datetime from metcalcpy.event_equalize import event_equalize -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH from metplotpy.plots.eclv.eclv_config import EclvConfig from metplotpy.plots.eclv.eclv_series import EclvSeries from metplotpy.plots.line.line import Line -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.series import Series diff --git a/metplotpy/plots/eclv/eclv_series.py b/metplotpy/plots/eclv/eclv_series.py index 94433a3ad..0c34becf3 100644 --- a/metplotpy/plots/eclv/eclv_series.py +++ b/metplotpy/plots/eclv/eclv_series.py @@ -18,7 +18,7 @@ from scipy.stats import norm import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from ..line.line_series import LineSeries @@ -40,8 +40,7 @@ def _create_series_points(self) -> list: Returns: dictionary with CI ,point values and number of stats as keys """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Creating series points: {datetime.now()}") # different ways to subset data for normal and derived series diff --git a/metplotpy/plots/ens_ss/ens_ss.py b/metplotpy/plots/ens_ss/ens_ss.py index f74d56378..a037ab29a 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -26,11 +26,11 @@ from plotly.graph_objects import Figure from metcalcpy.event_equalize import event_equalize -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.ens_ss.ens_ss_config import EnsSsConfig from metplotpy.plots.ens_ss.ens_ss_series import EnsSsSeries -from metplotpy.plots.base_plot import BasePlot -import metplotpy.plots.util as util +from metplotpy.plots.base_plot_plotly import BasePlot +import metplotpy.plots.util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/ens_ss/ens_ss_config.py b/metplotpy/plots/ens_ss/ens_ss_config.py index a1939721f..298428c5a 100644 --- a/metplotpy/plots/ens_ss/ens_ss_config.py +++ b/metplotpy/plots/ens_ss/ens_ss_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/ens_ss/ens_ss_series.py b/metplotpy/plots/ens_ss/ens_ss_series.py index 508a6e51f..28d599c68 100644 --- a/metplotpy/plots/ens_ss/ens_ss_series.py +++ b/metplotpy/plots/ens_ss/ens_ss_series.py @@ -19,7 +19,7 @@ import numpy as np import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from .. import GROUP_SEPARATOR from ..series import Series @@ -71,8 +71,7 @@ def _create_series_points(self) -> dict: Returns: dictionary with CI ,point values and number of stats as keys """ - ens_logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + ens_logger = util.get_common_logger(self.log_level, self.log_filename) ens_logger.info(f"Begin creating the series points: {datetime.now()}") # different ways to subset data for normal and derived series # this is a normal series diff --git a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py index 7cb366fab..60e9aaa7b 100644 --- a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py +++ b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds.py @@ -17,21 +17,20 @@ import re import csv -import yaml import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR from metplotpy.plots.equivalence_testing_bounds.equivalence_testing_bounds_series \ import EquivalenceTestingBoundsSeries from metplotpy.plots.line.line_config import LineConfig from metplotpy.plots.line.line_series import LineSeries -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util import metcalcpy.util.utils as calc_util diff --git a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py index 1548dc9cf..f91aa8be7 100644 --- a/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py +++ b/metplotpy/plots/equivalence_testing_bounds/equivalence_testing_bounds_series.py @@ -24,7 +24,7 @@ import metcalcpy.util.correlation as pg import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from metcalcpy.sum_stat import calculate_statistic from .. import GROUP_SEPARATOR from ..line.line_series import LineSeries @@ -54,8 +54,7 @@ def _create_series_points(self) -> dict: dictionary with CI ,point values and number of stats as keys """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Creating series points (calculating the values for " f"each point: {datetime.now()}") @@ -147,8 +146,7 @@ def _calculate_tost_paired(self, series_data_1: DataFrame, series_data_2: DataFr :param series_data_2: 2nd data frame sorted by fcst_init_beg """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Validating dataframe fcst_valid_beg: " f"{datetime.now()}") all_zero_1 = all(elem is None or math.isnan(elem) diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index fe12d96ef..0474f1d25 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -27,11 +27,11 @@ from plotly.graph_objects import Figure from metplotpy.plots.histogram import hist_config -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR from metplotpy.plots.histogram.hist_series import HistSeries -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util import metcalcpy.util.utils as utils from metcalcpy.event_equalize import event_equalize diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 16945ab62..5016e3473 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -16,9 +16,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 38335ba23..1e30222dc 100644 --- a/metplotpy/plots/histogram/hist_series.py +++ b/metplotpy/plots/histogram/hist_series.py @@ -17,7 +17,7 @@ import numpy as np import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from ..series import Series @@ -68,8 +68,7 @@ def _create_series_points(self) -> list: Returns: """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin creating the series points: {datetime.now()}") all_filters = [] diff --git a/metplotpy/plots/histogram/histogram.py b/metplotpy/plots/histogram/histogram.py index 231d333f3..4ef52dea8 100644 --- a/metplotpy/plots/histogram/histogram.py +++ b/metplotpy/plots/histogram/histogram.py @@ -13,13 +13,12 @@ """ __author__ = 'Tatiana Burek' -import os import plotly.graph_objects as go import yaml import pandas as pd import numpy as np -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot class Histogram(BasePlot): diff --git a/metplotpy/plots/histogram/prob_hist.py b/metplotpy/plots/histogram/prob_hist.py index d4cd7aae4..c9e2ba57e 100644 --- a/metplotpy/plots/histogram/prob_hist.py +++ b/metplotpy/plots/histogram/prob_hist.py @@ -18,7 +18,7 @@ from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util class ProbHist(Hist): diff --git a/metplotpy/plots/histogram/rank_hist.py b/metplotpy/plots/histogram/rank_hist.py index a9a054b9b..0a54ee6fb 100644 --- a/metplotpy/plots/histogram/rank_hist.py +++ b/metplotpy/plots/histogram/rank_hist.py @@ -13,10 +13,9 @@ """ __author__ = 'Tatiana Burek' -import yaml from datetime import datetime from metplotpy.plots.histogram.hist import Hist -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.histogram.hist_series import HistSeries diff --git a/metplotpy/plots/histogram/rel_hist.py b/metplotpy/plots/histogram/rel_hist.py index 3040451e7..5035fcd18 100644 --- a/metplotpy/plots/histogram/rel_hist.py +++ b/metplotpy/plots/histogram/rel_hist.py @@ -13,10 +13,9 @@ """ __author__ = 'Tatiana Burek' -import yaml from datetime import datetime -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries diff --git a/metplotpy/plots/histogram_2d/histogram_2d.py b/metplotpy/plots/histogram_2d/histogram_2d.py index b7f35159d..50ef0e085 100644 --- a/metplotpy/plots/histogram_2d/histogram_2d.py +++ b/metplotpy/plots/histogram_2d/histogram_2d.py @@ -29,12 +29,12 @@ import xarray as xr import plotly.graph_objects as go -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util """ Import BasePlot class """ -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot class Histogram_2d(BasePlot): diff --git a/metplotpy/plots/line/line.py b/metplotpy/plots/line/line.py index b978049c2..0cbe7fcf0 100644 --- a/metplotpy/plots/line/line.py +++ b/metplotpy/plots/line/line.py @@ -20,7 +20,6 @@ from typing import Union from itertools import chain -import yaml import numpy as np import pandas as pd @@ -28,12 +27,12 @@ from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ PLOTLY_PAPER_BGCOOR from metplotpy.plots.line.line_config import LineConfig from metplotpy.plots.line.line_series import LineSeries -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util from metplotpy.plots.series import Series import metcalcpy.util.utils as calc_util diff --git a/metplotpy/plots/line/line_config.py b/metplotpy/plots/line/line_config.py index 39149d967..ffb383344 100644 --- a/metplotpy/plots/line/line_config.py +++ b/metplotpy/plots/line/line_config.py @@ -17,9 +17,9 @@ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/line/line_series.py b/metplotpy/plots/line/line_series.py index 04c644e47..cea0dd7a9 100644 --- a/metplotpy/plots/line/line_series.py +++ b/metplotpy/plots/line/line_series.py @@ -26,7 +26,7 @@ from scipy.stats import norm import metcalcpy.util.utils as utils -import metplotpy.plots.util +import metplotpy.plots.util_plotly as util from ..series import Series from .. import GROUP_SEPARATOR @@ -50,8 +50,7 @@ def __init__(self, config, idx: int, input_data, series_list: list, # Retrieve any fixed variables - self.logger = metplotpy.plots.util.get_common_logger(config.log_level, - config.log_filename) + self.logger = util.get_common_logger(config.log_level, config.log_filename) def _create_all_fields_values_no_indy(self) -> dict: """ @@ -91,8 +90,7 @@ def _calc_point_stat(self, data: list) -> Union[float, None]: :return: mean, median or sum of the values from the input list or None if the statistic parameter is invalid """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin calculating plot_stat parameter: " f"{datetime.now()}") # calculate point stat @@ -111,8 +109,7 @@ def _calc_point_stat(self, data: list) -> Union[float, None]: else: point_stat = None - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin calculating plot_stat parameter: " f"{datetime.now()}") return point_stat @@ -127,8 +124,7 @@ def _create_series_points(self) -> dict: Returns: dictionary with CI ,point values and number of stats as keys """ - logger = metplotpy.plots.util.get_common_logger(self.log_level, - self.log_filename) + logger = util.get_common_logger(self.log_level, self.log_filename) logger.info(f"Begin calculating values for each series point: " f"{datetime.now()}") series_data_1 = None @@ -141,8 +137,8 @@ def _create_series_points(self) -> dict: # @nan_val is substituted for the 'NA' in the list of values # that correspond to a column. - filtered_df = metplotpy.plots.util.filter_by_fixed_vars(self.input_data, - self.config.fixed_vars_vals) + filtered_df = util.filter_by_fixed_vars(self.input_data, + self.config.fixed_vars_vals) else: # Nothing specified in the fixed_vars_vals_input setting, # use the original input data diff --git a/metplotpy/plots/mpr_plot/mpr_plot.py b/metplotpy/plots/mpr_plot/mpr_plot.py index 90a77fccf..5cfbeb0a5 100644 --- a/metplotpy/plots/mpr_plot/mpr_plot.py +++ b/metplotpy/plots/mpr_plot/mpr_plot.py @@ -23,12 +23,12 @@ from plotly.subplots import make_subplots import plotly.io as pio -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.mpr_plot.mpr_plot_config import MprPlotConfig from metplotpy.plots.wind_rose.wind_rose import WindRosePlot -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util class MprPlotInfo(): diff --git a/metplotpy/plots/performance_diagram/performance_diagram_config.py b/metplotpy/plots/performance_diagram/performance_diagram_config.py index 263227f88..1863b9859 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram_config.py +++ b/metplotpy/plots/performance_diagram/performance_diagram_config.py @@ -67,8 +67,8 @@ def __init__(self, parameters): self.linewidth_list = self._get_linewidths() self.linestyles_list = self._get_linestyles() self.user_legends = self._get_user_legends("Performance") - self.plot_width = self.calculate_plot_dimension('plot_width', 'in') - self.plot_height = self.calculate_plot_dimension('plot_height', 'in') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') # x-axis labels and x-axis ticks self.x_title_font_size = self.parameters['xlab_size'] * constants.DEFAULT_CAPTION_FONTSIZE diff --git a/metplotpy/plots/polar_plot/polar_plot.py b/metplotpy/plots/polar_plot/polar_plot.py index 4b5a9f999..0703d1e0d 100644 --- a/metplotpy/plots/polar_plot/polar_plot.py +++ b/metplotpy/plots/polar_plot/polar_plot.py @@ -41,9 +41,7 @@ """ Import BasePlot class """ -from plots.base_plot import BasePlot -#from ..base_plot import BasePlot - +from metplotpy.plots.base_plot import BasePlot class PolarPlot(BasePlot): diff --git a/metplotpy/plots/reliability_diagram/reliability.py b/metplotpy/plots/reliability_diagram/reliability.py index fc928af71..aad7eab9c 100644 --- a/metplotpy/plots/reliability_diagram/reliability.py +++ b/metplotpy/plots/reliability_diagram/reliability.py @@ -26,9 +26,9 @@ from plotly.subplots import make_subplots from plotly.graph_objects import Figure -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots import util +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots import util_plotly as util from metplotpy.plots.reliability_diagram.reliability_config import ReliabilityConfig from metplotpy.plots.reliability_diagram.reliability_series import ReliabilitySeries diff --git a/metplotpy/plots/reliability_diagram/reliability_config.py b/metplotpy/plots/reliability_diagram/reliability_config.py index d011c054c..3f1175d1c 100644 --- a/metplotpy/plots/reliability_diagram/reliability_config.py +++ b/metplotpy/plots/reliability_diagram/reliability_config.py @@ -18,9 +18,9 @@ import itertools from datetime import datetime -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util class ReliabilityConfig(Config): diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index bfca2af43..35aa020b0 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -15,13 +15,13 @@ from datetime import datetime import plotly.graph_objects as go -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.box.box import Box -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util import metcalcpy.util.utils as calc_util -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH from metplotpy.plots.revision_box.revision_box_config import RevisionBoxConfig from metplotpy.plots.revision_box.revision_box_series import RevisionBoxSeries diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index 8f74d50f7..873100a1d 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -14,9 +14,9 @@ """ import itertools -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/revision_series/revision_series.py b/metplotpy/plots/revision_series/revision_series.py index 03e6cafdb..e03ea8ffa 100644 --- a/metplotpy/plots/revision_series/revision_series.py +++ b/metplotpy/plots/revision_series/revision_series.py @@ -21,11 +21,11 @@ import plotly.graph_objects as go -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.line.line import Line -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.series import Series import metcalcpy.util.utils as calc_util diff --git a/metplotpy/plots/revision_series/revision_series_config.py b/metplotpy/plots/revision_series/revision_series_config.py index d031be4f5..f7e5a0d89 100644 --- a/metplotpy/plots/revision_series/revision_series_config.py +++ b/metplotpy/plots/revision_series/revision_series_config.py @@ -14,9 +14,9 @@ """ import itertools from datetime import datetime -from ..config import Config -from .. import constants -from .. import util +from ..config_plotly import Config +from .. import constants_plotly as constants +from .. import util_plotly as util import metcalcpy.util.utils as utils diff --git a/metplotpy/plots/roc_diagram/roc_diagram.py b/metplotpy/plots/roc_diagram/roc_diagram.py index 01bcdd811..dfd840355 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram.py +++ b/metplotpy/plots/roc_diagram/roc_diagram.py @@ -17,20 +17,18 @@ from datetime import datetime import re import warnings -# with warnings.catch_warnings(): -# warnings.simplefilter("ignore", category="DeprecationWarning") -# warnings.simplefilter("ignore", category="ResourceWarning") import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots -from metplotpy.plots import util -from metplotpy.plots import constants -from metplotpy.plots.base_plot import BasePlot + +from metplotpy.plots import util_plotly as util +from metplotpy.plots import constants_plotly as constants +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.roc_diagram.roc_diagram_config import ROCDiagramConfig from metplotpy.plots.roc_diagram.roc_diagram_series import ROCDiagramSeries + import metcalcpy.util.utils as calc_util -from metplotpy.plots.util import prepare_pct_roc, prepare_ctc_roc class ROCDiagram(BasePlot): @@ -236,7 +234,7 @@ def _create_series(self, input_data): 'fn_on': group_stats_fn_on, } df_summary_curve.reset_index() - pody, pofd, thresh = prepare_ctc_roc(df_summary_curve,self.config_obj.ctc_ascending) + pody, pofd, thresh = util.prepare_ctc_roc(df_summary_curve,self.config_obj.ctc_ascending) else: df_summary_curve = pd.DataFrame(columns=['thresh_i', 'on_i', 'oy_i']) thresh_i_list = df_sum_main['thresh_i'].unique() @@ -250,7 +248,7 @@ def _create_series(self, input_data): df_summary_curve.loc[len(df_summary_curve)] = {'thresh_i': thresh, 'on_i': on_i_sum, 'oy_i': oy_i_sum, } df_summary_curve.reset_index() - pody, pofd, thresh = prepare_pct_roc(df_summary_curve) + pody, pofd, thresh = util.prepare_pct_roc(df_summary_curve) series_obj = ROCDiagramSeries(self.config_obj, num_series -1, None) series_obj.series_points = (pofd, pody, thresh, None) diff --git a/metplotpy/plots/roc_diagram/roc_diagram_config.py b/metplotpy/plots/roc_diagram/roc_diagram_config.py index 3f11e0b85..a2ca8a01b 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_config.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_config.py @@ -15,11 +15,9 @@ """ __author__ = 'Minna Win' - -import sys -from ..config import Config -from .. import util -from .. import constants +from ..config_plotly import Config +from .. import util_plotly as util +from .. import constants_plotly as constants class ROCDiagramConfig(Config): def __init__(self, parameters): diff --git a/metplotpy/plots/roc_diagram/roc_diagram_series.py b/metplotpy/plots/roc_diagram/roc_diagram_series.py index 9d3f59c24..81be29fe8 100644 --- a/metplotpy/plots/roc_diagram/roc_diagram_series.py +++ b/metplotpy/plots/roc_diagram/roc_diagram_series.py @@ -17,7 +17,7 @@ import pandas as pd import metcalcpy.util.utils as utils from ..series import Series -from ..util import prepare_pct_roc, prepare_ctc_roc +from ..util_plotly import prepare_pct_roc, prepare_ctc_roc class ROCDiagramSeries(Series): diff --git a/metplotpy/plots/scatter/scatter.py b/metplotpy/plots/scatter/scatter.py index 2f13b5888..3abaead7f 100644 --- a/metplotpy/plots/scatter/scatter.py +++ b/metplotpy/plots/scatter/scatter.py @@ -16,13 +16,11 @@ import matplotlib.pyplot as plt import numpy as np from matplotlib.font_manager import FontProperties -import yaml + import pandas as pd from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.scatter.scatter_config import ScatterConfig from metplotpy.plots import util -from metplotpy.plots.util import get_params -from metcalcpy.util.read_env_vars_in_config import parse_config class Scatter(BasePlot): """ @@ -201,7 +199,7 @@ def main(config_filename=None): Returns: None """ - docs = get_params(config_filename) + docs = util.get_params(config_filename) try: plot = Scatter(docs) diff --git a/metplotpy/plots/scatter/scatter_config.py b/metplotpy/plots/scatter/scatter_config.py index 0e5c5665c..1d471ef3e 100644 --- a/metplotpy/plots/scatter/scatter_config.py +++ b/metplotpy/plots/scatter/scatter_config.py @@ -15,7 +15,6 @@ from .. import constants from .. import util -import metcalcpy.util.utils as utils class ScatterConfig(Config): """ Configuration object for the scatter plot. @@ -99,8 +98,8 @@ def __init__(self, parameters): if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x_tickangle = constants.XAXIS_ORIENTATION[self.x_tickangle] self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) - + xlab_weight = self.parameters['xlab_weight'] + self.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[xlab_weight] ############################################## self.marker_symbol = self._get_marker() diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram.py b/metplotpy/plots/taylor_diagram/taylor_diagram.py index b6b7bc54c..f029da971 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram.py @@ -279,34 +279,25 @@ def _create_figure(self) -> None: self.ax.plot(np.arccos(correlation), stdev, marker=marker, ms=10, ls='', color=marker_colors, label=legend) - # use FontProperties to re-create the weights used in METviewer - fontobj = FontProperties() - font_title = fontobj.copy() - font_title.set_size(self.config_obj.title_size) - style = self.config_obj.title_weight[0] - wt = self.config_obj.title_weight[1] - font_title.set_style(style) - font_title.set_weight(wt) - - plt.title(self.config_obj.title, - fontproperties=font_title, - color=constants.DEFAULT_TITLE_COLOR, - pad=28) - - # Plot the caption, leverage FontProperties to re-create the 'weights' menu in - # METviewer (i.e. use a combination of style and weight to create the bold - # italic - # caption weight in METviewer) - fontobj = FontProperties() - font = fontobj.copy() - font.set_size(self.config_obj.caption_size) - style = self.config_obj.caption_weight[0] - wt = self.config_obj.caption_weight[1] - font.set_style(style) - font.set_weight(wt) - plt.figtext(self.config_obj.caption_align, self.config_obj.caption_offset, - self.config_obj.plot_caption, - fontproperties=font, color=self.config_obj.caption_color) + # get the weights, sizes, and style for the title, caption, x-axis label, and + # y-axis label + wts_size_styles = self.get_weights_size_styles() + + # Plot the title + plt.title( + self.config_obj.title, + fontproperties=wts_size_styles['title'], + color=constants.DEFAULT_TITLE_COLOR, + pad=28 + ) + + # Plot the caption + + plt.figtext( + self.config_obj.caption_align, self.config_obj.caption_offset, + self.config_obj.plot_caption, + fontproperties=wts_size_styles['caption'], color=self.config_obj.caption_color + ) # Add a figure legend @@ -331,6 +322,7 @@ def _create_figure(self) -> None: plt.tight_layout() plt.plot() + # Save the figure, based on whether we are displaying only positive # correlations or all # correlations. diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py index fd556cb40..c7696cfa2 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram_config.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py @@ -64,8 +64,8 @@ def __init__(self, parameters: dict) -> None: # Convert the plot height and width to inches if units aren't in # inches. if self.plot_units.lower() != 'in': - self.plot_width = self.calculate_plot_dimension('plot_width', 'in') - self.plot_height = self.calculate_plot_dimension('plot_height', 'in') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') else: self.plot_width = self.get_config_value('plot_width') self.plot_height = self.get_config_value('plot_height') diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py index ec0861118..f4367821b 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py @@ -5,7 +5,7 @@ from metplotpy.plots.tcmpr_plots.box.tcmpr_box_point import TcmprBoxPoint from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprBox(TcmprBoxPoint): diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py index bff7b448f..f39de2797 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_box_point.py @@ -2,7 +2,7 @@ from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprBoxPoint(Tcmpr): def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_data, stat_name): diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py index 44cab41b3..c44889993 100755 --- a/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py +++ b/metplotpy/plots/tcmpr_plots/box/tcmpr_point.py @@ -3,7 +3,7 @@ import plotly.graph_objects as go -from metplotpy.plots import util +from metplotpy.plots import util_plotly as util from metplotpy.plots.tcmpr_plots.box.tcmpr_box_point import TcmprBoxPoint from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries diff --git a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py index e73a9608c..20c6fea55 100755 --- a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py +++ b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_line_mean.py @@ -3,7 +3,7 @@ from metplotpy.plots.tcmpr_plots.line.mean.tcmpr_series_line_mean import TcmprSeriesLineMean from metplotpy.plots.tcmpr_plots.line.tcmpr_line import TcmprLine -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprLineMean(TcmprLine): diff --git a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py index cc013ca67..fc5333884 100755 --- a/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py +++ b/metplotpy/plots/tcmpr_plots/line/mean/tcmpr_series_line_mean.py @@ -19,7 +19,7 @@ import metcalcpy.util.utils as utils from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import get_mean_ci -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSeriesLineMean(TcmprSeries): diff --git a/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py b/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py index a5cced495..4352c990a 100755 --- a/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py +++ b/metplotpy/plots/tcmpr_plots/line/median/tcmpr_line_median.py @@ -3,7 +3,7 @@ from metplotpy.plots.tcmpr_plots.line.median.tcmpr_series_line_median import TcmprSeriesLineMedian from metplotpy.plots.tcmpr_plots.line.tcmpr_line import TcmprLine -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprLineMedian(TcmprLine): diff --git a/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py b/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py index f759e839f..b43593cdd 100755 --- a/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py +++ b/metplotpy/plots/tcmpr_plots/line/tcmpr_line.py @@ -3,7 +3,7 @@ from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprLine(Tcmpr): def __init__(self, config_obj, column_info, col, case_data, input_df, baseline_data, stat_name): diff --git a/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py b/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py index 8d348dccc..49dcb2652 100755 --- a/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py +++ b/metplotpy/plots/tcmpr_plots/rank/tcmpr_rank.py @@ -17,10 +17,9 @@ import plotly.graph_objects as go from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr -from metplotpy.plots.tcmpr_plots.tcmpr_config import TcmprConfig from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import get_case_data -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprRank(Tcmpr): diff --git a/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py b/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py index f6c350896..377121db5 100755 --- a/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py +++ b/metplotpy/plots/tcmpr_plots/relperf/tcmpr_relperf.py @@ -1,17 +1,14 @@ import os -from typing import Union from datetime import datetime import numpy as np -from pandas import DataFrame import plotly.graph_objects as go from metcalcpy.util import utils -from metplotpy.plots.series import Series from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import get_case_data -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util diff --git a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py index d7632c174..06de9ce45 100755 --- a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py +++ b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_series_skill_mean.py @@ -16,7 +16,7 @@ import numpy as np from pandas import DataFrame from datetime import datetime -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util import metcalcpy.util.utils as utils from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries diff --git a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py index 1f11bffc5..500c7f502 100755 --- a/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py +++ b/metplotpy/plots/tcmpr_plots/skill/mean/tcmpr_skill_mean.py @@ -7,7 +7,7 @@ from metcalcpy.util import utils from metplotpy.plots.tcmpr_plots.skill.mean.tcmpr_series_skill_mean import TcmprSeriesSkillMean from metplotpy.plots.tcmpr_plots.skill.tcmpr_skill import TcmprSkill -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSkillMean(TcmprSkill): diff --git a/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py b/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py index cc0d9406c..bdd2872d8 100755 --- a/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py +++ b/metplotpy/plots/tcmpr_plots/skill/median/tcmpr_skill_median.py @@ -4,7 +4,7 @@ from metcalcpy.util import utils from metplotpy.plots.tcmpr_plots.skill.median.tcmpr_series_skill_median import TcmprSeriesSkillMedian from metplotpy.plots.tcmpr_plots.skill.tcmpr_skill import TcmprSkill -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSkillMedian(TcmprSkill): diff --git a/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py b/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py index 795dbe7e4..ef6d67974 100755 --- a/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py +++ b/metplotpy/plots/tcmpr_plots/skill/tcmpr_skill.py @@ -5,7 +5,7 @@ from metplotpy.plots.tcmpr_plots.tcmpr import Tcmpr from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metcalcpy.util import utils -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSkill(Tcmpr): diff --git a/metplotpy/plots/tcmpr_plots/tcmpr.py b/metplotpy/plots/tcmpr_plots/tcmpr.py index 41ddfb957..32d926842 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr.py @@ -22,15 +22,15 @@ import numpy as np import pandas as pd import plotly.graph_objects as go -import yaml + from plotly.graph_objects import Figure from plotly.subplots import make_subplots import metcalcpy.util.utils as calc_util from metcalcpy.event_equalize import event_equalize -from metplotpy.plots import util -from metplotpy.plots.base_plot import BasePlot -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots import util_plotly as util +from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots.tcmpr_plots.tcmpr_config import TcmprConfig from metplotpy.plots.tcmpr_plots.tcmpr_series import TcmprSeries from metplotpy.plots.tcmpr_plots.tcmpr_util import init_hfip_baseline, common_member, get_dep_column diff --git a/metplotpy/plots/tcmpr_plots/tcmpr_config.py b/metplotpy/plots/tcmpr_plots/tcmpr_config.py index 0d75e358f..7fc6de59f 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr_config.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr_config.py @@ -17,10 +17,9 @@ import itertools import metcalcpy.util.utils as utils -from .. import constants -from .. import util -from ..config import Config -import metplotpy.plots.util as util +from .. import constants_plotly as constants +from ..config_plotly import Config +import metplotpy.plots.util_plotly as util class TcmprConfig(Config): diff --git a/metplotpy/plots/tcmpr_plots/tcmpr_series.py b/metplotpy/plots/tcmpr_plots/tcmpr_series.py index 3d88bfef6..f3dcab377 100755 --- a/metplotpy/plots/tcmpr_plots/tcmpr_series.py +++ b/metplotpy/plots/tcmpr_plots/tcmpr_series.py @@ -22,7 +22,7 @@ import metcalcpy.util.utils as utils from .tcmpr_util import get_prop_ci from ..series import Series -import metplotpy.plots.util as util +import metplotpy.plots.util_plotly as util class TcmprSeries(Series): diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index ad7c99549..82011cbc3 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -23,7 +23,8 @@ import numpy as np from typing import Union import pandas as pd -from plotly.graph_objects import Figure +import matplotlib.pyplot as plt + from metplotpy.plots.context_filter import ContextFilter as cf import metcalcpy.util.pstd_statistics as pstats import metcalcpy.util.ctc_statistics as cstats @@ -85,31 +86,6 @@ def get_params(config_filename): return parse_config(config_file) -def make_plot(config_filename, plot_class): - """!Get plot parameters and create the plot. - - @param config_filename The full path to the config or None - @param plot_class class of plot to produce, e.g. Bar or Box - @returns plot class object or None if something went wrong - """ - # Retrieve the contents of the custom config file to over-ride - # or augment settings defined by the default config file. - params = get_params(config_filename) - try: - plot = plot_class(params) - plot.save_to_file() - #if plot.config_obj.show_in_browser: - # plot.show_in_browser() - plot.write_html() - plot.write_output_file() - name = plot_class.__name__ if not hasattr(plot_class, 'LONG_NAME') else plot_class.LONG_NAME - plot.logger.info(f"Finished {name} plot at {datetime.now()}") - return plot - except ValueError as val_er: - print(val_er) - - return None - def alpha_blending(hex_color: str, alpha: float) -> str: """ Alpha color blending as if on the white background. @@ -126,26 +102,25 @@ def alpha_blending(hex_color: str, alpha: float) -> str: return matplotlib.colors.rgb2hex(final) -def apply_weight_style(text: str, weight: int) -> str: - """ - Applied HTML style weight to text: - 1 - none - 2 - bold - 3 - italic - 4 - bold italic - - :param text: text to style - :param weight: - int representation of the style - :return: styled text +def get_font_params(weight: int) -> dict: + """Convert integer font style/weight value to a dictionary of + font properties, fontweight for bold and fontstyle for italic. + 1=plain text, 2=bold, 3=italic, 4=bold italic + REMOVE: Replaces apply_weight_style function used for plotly. + + @param weight integer representation of the style/weight + @returns dictionary containing font properties like fontweight and fontstyle """ - if len(text) > 0: - if weight == 2: - return '' + text + '' - if weight == 3: - return '' + text + '' - if weight == 4: - return '' + text + '' - return text + font_params = { + 'fontweight': 'normal', + 'fontstyle': 'normal', + } + if weight in (2, 4): + font_params['fontweight'] = 'bold' + if weight in (3, 4): + font_params['fontstyle'] = 'italic' + + return font_params def nicenumber(x, to_round): @@ -200,38 +175,6 @@ def pretty(low, high, number_of_intervals) -> Union[np.ndarray, list]: return np.arange(miny, maxy + 0.5 * d, d) -def add_horizontal_line(figure: Figure, y: float, line_properties: dict) -> None: - """ - Adds a horizontal line to the Plotly Figure - :param figure: Plotly plot to add a line to - :param y: y value for the line - :param line_properties: dictionary with line properties like color, width, dash - :return: - """ - figure.add_shape( - type='line', - yref='y', y0=y, y1=y, - xref='paper', x0=0, x1=1, - line=line_properties, - ) - - -def add_vertical_line(figure: Figure, x: float, line_properties: dict) -> None: - """ - Adds a vertical line to the Plotly Figure - :param figure: Plotly plot to add a line to - :param x: x value for the line - :param line_properties: dictionary with line properties like color, width, dash - :return: - """ - figure.add_shape( - type='line', - yref='paper', y0=0, y1=1, - xref='x', x0=x, x1=x, - line=line_properties, - ) - - def abline(x_value: float, intercept: float, slope: float) -> float: """ Calculates y coordinate based on x-value, intercept and slope diff --git a/metplotpy/plots/util_plotly.py b/metplotpy/plots/util_plotly.py new file mode 100644 index 000000000..ad7c99549 --- /dev/null +++ b/metplotpy/plots/util_plotly.py @@ -0,0 +1,698 @@ +# ============================* +# ** Copyright UCAR (c) 2020 +# ** University Corporation for Atmospheric Research (UCAR) +# ** National Center for Atmospheric Research (NCAR) +# ** Research Applications Lab (RAL) +# ** P.O.Box 3000, Boulder, Colorado, 80307-3000, USA +# ============================* + + +""" + Collection of utility functions used by multiple plotting classes +""" +__author__ = 'Minna Win' + +import argparse +import sys +import os +import logging +import gc +import re +from datetime import datetime +import matplotlib +import numpy as np +from typing import Union +import pandas as pd +from plotly.graph_objects import Figure +from metplotpy.plots.context_filter import ContextFilter as cf +import metcalcpy.util.pstd_statistics as pstats +import metcalcpy.util.ctc_statistics as cstats +from metcalcpy.util.read_env_vars_in_config import parse_config + +COLORSCALES = { + 'green_red': ['#E6FFE2', '#B3FAAD', '#74F578', '#30D244', '#00A01E', '#F6A1A2', + '#E26667', '#C93F41', '#A42526'], + 'blue_white_brown': ['#1962CF', '#3E94F2', '#B4F0F9', '#00A01E', '#4AF058', + '#C7FFC0', '#FFFFFF', '#FFE97F', + '#FF3A20', '#A50C0F', '#E1BFB5', '#A0786F', '#643D34'], + 'cm_colors': ["#80FFFF", "#95FFFF", "#AAFFFF", "#BFFFFF", "#D4FFFF", "#EAFFFF", + "#FFFFFF", "#FFEAFF", "#FFD5FF", + "#FFBFFF", "#FFAAFF", "#FF95FF", "#FF80FF"], + 'topo_colors': ["#4C00FF", "#0000FF", "#004CFF", "#0099FF", "#00E5FF", "#00FF4D", + "#1AFF00", "#80FF00", "#E6FF00", + "#FFFF00", "#FFE53B", "#FFDB77", "#FFE0B3"], + 'terrain_colors': ["#00A600", "#24B300", "#4CBF00", "#7ACC00", "#ADD900", "#E6E600", + "#E7CB21", "#E9BA43", + "#EBB165", "#EDB387", "#EFBEAA", "#F0D3CE", "#F2F2F2"], + 'heat_colors': ["#FF0000", "#FF1C00", "#FF3900", "#FF5500", "#FF7100", "#FF8E00", + "#FFAA00", "#FFC600", "#FFE300", + "#FFFF00", "#FFFF2A", "#FFFF80", "#FFFFD5"], + 'rainbow': ["#FF0000", "#FF7600", "#FFEB00", "#9DFF00", "#27FF00", "#00FF4E", + "#00FFC4", "#00C4FF", "#004EFF", + "#2700FF", "#9D00FF", "#FF00EB", "#FF0076"] +} + + +def read_config_from_command_line(): + """ + Read the "custom" config file from the command line + + Args: + + Returns: + The full path to the config file + """ + # Create Parser + parser = argparse.ArgumentParser(description='Read in config file') + + # Add arguments + parser.add_argument('Path', metavar='path', type=str, + help='the full path to config file') + + # Execute the parse_args() method + args = parser.parse_args() + return args.Path + + +def get_params(config_filename): + """!Read config_filename or get config file from command line, then parse + config file and return it as a dictionary. + + @param config_filename The full path to the config file or None + @returns dictionary containing parameters for plot + """ + config_file = config_filename if config_filename else read_config_from_command_line() + return parse_config(config_file) + + +def make_plot(config_filename, plot_class): + """!Get plot parameters and create the plot. + + @param config_filename The full path to the config or None + @param plot_class class of plot to produce, e.g. Bar or Box + @returns plot class object or None if something went wrong + """ + # Retrieve the contents of the custom config file to over-ride + # or augment settings defined by the default config file. + params = get_params(config_filename) + try: + plot = plot_class(params) + plot.save_to_file() + #if plot.config_obj.show_in_browser: + # plot.show_in_browser() + plot.write_html() + plot.write_output_file() + name = plot_class.__name__ if not hasattr(plot_class, 'LONG_NAME') else plot_class.LONG_NAME + plot.logger.info(f"Finished {name} plot at {datetime.now()}") + return plot + except ValueError as val_er: + print(val_er) + + return None + + +def alpha_blending(hex_color: str, alpha: float) -> str: + """ Alpha color blending as if on the white background. + Useful for gridlines + + Args: + @param hex_color - the color in hex + @param alpha - Alpha value between 0 and 1 + Returns: blended hex color + """ + foreground_tuple = matplotlib.colors.hex2color(hex_color) + foreground_arr = np.array(foreground_tuple) + final = tuple((1. - alpha) + foreground_arr * alpha) + return matplotlib.colors.rgb2hex(final) + + +def apply_weight_style(text: str, weight: int) -> str: + """ + Applied HTML style weight to text: + 1 - none + 2 - bold + 3 - italic + 4 - bold italic + + :param text: text to style + :param weight: - int representation of the style + :return: styled text + """ + if len(text) > 0: + if weight == 2: + return '' + text + '' + if weight == 3: + return '' + text + '' + if weight == 4: + return '' + text + '' + return text + + +def nicenumber(x, to_round): + """ + Calculates a close nice number, i. e. a number with simple decimals. + :param x: A number + :param to_round: Should the number be rounded? + :return: A number with simple decimals + """ + exp = np.floor(np.log10(x)) + f = x / 10 ** exp + + if to_round: + if f < 1.5: + nf = 1. + elif f < 3.: + nf = 2. + elif f < 7.: + nf = 5. + else: + nf = 10. + else: + if f <= 1.: + nf = 1. + elif f <= 2.: + nf = 2. + elif f <= 5.: + nf = 5. + else: + nf = 10. + + return nf * 10. ** exp + + +def pretty(low, high, number_of_intervals) -> Union[np.ndarray, list]: + """ + Compute a sequence of about n+1 equally spaced ‘round’ values which cover the + range of the values in x + Can be used to create axis labels or bins + :param low: min value + :param high: max value + :param number_of_intervals: number of intervals + :return: + """ + if number_of_intervals == 1: + return [-1, 0] + + num_range = nicenumber(high - low, False) + d = nicenumber(num_range / (number_of_intervals - 1), True) + miny = np.floor(low / d) * d + maxy = np.ceil(high / d) * d + return np.arange(miny, maxy + 0.5 * d, d) + + +def add_horizontal_line(figure: Figure, y: float, line_properties: dict) -> None: + """ + Adds a horizontal line to the Plotly Figure + :param figure: Plotly plot to add a line to + :param y: y value for the line + :param line_properties: dictionary with line properties like color, width, dash + :return: + """ + figure.add_shape( + type='line', + yref='y', y0=y, y1=y, + xref='paper', x0=0, x1=1, + line=line_properties, + ) + + +def add_vertical_line(figure: Figure, x: float, line_properties: dict) -> None: + """ + Adds a vertical line to the Plotly Figure + :param figure: Plotly plot to add a line to + :param x: x value for the line + :param line_properties: dictionary with line properties like color, width, dash + :return: + """ + figure.add_shape( + type='line', + yref='paper', y0=0, y1=1, + xref='x', x0=x, x1=x, + line=line_properties, + ) + + +def abline(x_value: float, intercept: float, slope: float) -> float: + """ + Calculates y coordinate based on x-value, intercept and slope + :param x_value: x coordinate + :param intercept: intercept + :param slope: slope + :return: y value + """ + return slope * x_value + intercept + + +def is_threshold_value(values: Union[pd.core.series.Series, list]): + """ + Determines if a pandas Series of values are threshold values (e.g. '>=1', '<5.0', + '>21') + + Args: + @param values: pandas Series of independent variables comprising the x-axis + + Returns: + A tuple of boolean values: + True if any of these values is a threshold (ie. operator and number) and True if + these are mixed threshold + (==SFP50,==FBIAS1, etc. ). False otherwise. + + """ + + thresh_ctr = 0 + percent_thresh_ctr = 0 + is_percent_thresh = False + is_thresh = False + # Check all the threshold values, there may be some threshold values that do not + # have an equality operator when equality is implied. + for cur_value in values: + match_pct = re.match( + r'(\<|\<=|\==|\>=|\>)(\s)*(SFP|SOP|SCP|USP|CDP|FBIAS)(\s)*([+-]?([0-9]*[' + r'.])?[0-9]+)', + str(cur_value)) + match_thresh = re.match(r'(\<|\<=|\==|\>=|\>)(\s)*([+-]?([0-9]*[.])?[0-9]+)', + str(cur_value)) + if match_pct: + # This is a percent threshold, with values like '==FBIAS1'. + percent_thresh_ctr += 1 + elif match_thresh: + thresh_ctr += 1 + + if thresh_ctr >= 1: + is_thresh = True + if percent_thresh_ctr >= 1: + is_percent_thresh = True + + return is_thresh, is_percent_thresh + + +def sort_threshold_values(thresh_values: pd.core.series.Series) -> list: + """ + Sort the threshold values based on operator and numerical value + + Args: + @param thresh_values: a pandas Series of threshold values (operator + number) + + Return: + sorted_thresholds: A list of threshold values as strings (operator+numerical + value) + """ + + operators = [] + values = [] + for cur_val in thresh_values: + # treat the thresh value as comprised of two groups, one + # for the operator and the other for the value (which can be a + # negative value) + match = re.match(r'(\<|\<=|\==|\>=|\>)(\s)*([+-]?([0-9]*[.])?[0-9]+)', + str(cur_val)) + if match: + operators.append(match.group(1)) + value = float(match.group(3)) + values.append(value) + else: + # This is a bare number (float or int) + operators.append(None) + values.append(float(cur_val)) + + # Apply weights to the operators + wt_maps = {'<': 1, '<=': 2, '==': 3, '>=': 4, '>': 5} + wts = [] + + for operator in operators: + # assign weight for == if no + # operator is indicated, assuming + # that a fcst_thresh of 5 is the same as + # ==5 + # otherwise, assign the appropriate weight to + # the operator + if operator is None: + wts.append(3) + else: + wts.append(wt_maps[operator]) + + # Create a pandas dataframe to use the ability to sort by multiple columns + thresh_dict = {'thresh': thresh_values, 'thresh_values': values, 'op_wts': wts} + df = pd.DataFrame(thresh_dict) + + # cols is the list of columns upon which we should sort + twocols = ['thresh_values', 'op_wts'] + sorted_val_wt = df.sort_values(by=twocols, inplace=False, ascending=True, + ignore_index=True) + + # now the dataframe has the xyz_thresh values sorted appropriately + return sorted_val_wt['thresh'] + + +def get_common_logger(log_level, log_filename): + ''' + Args: + @param log_level: The log level + @param log_filename: The full path to the log file + filename + Returns: + common_logger: the logger common to all the METplotpy modules that are + currently in use by a plot type. + ''' + + # If directory for logfile doesn't exist, create it + log_dir = os.path.dirname(log_filename) + try: + os.makedirs(log_dir, exist_ok=True) + except OSError: + pass + + # Supported log levels. + log_level = log_level.upper() + log_levels = {'DEBUG': logging.DEBUG, 'INFO': logging.INFO, + 'WARNING': logging.WARNING, 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL} + + if log_filename.lower() == 'stdout': + logging.basicConfig(level=log_levels[log_level], + format='%(asctime)s||User:%(' + 'user)s||%(funcName)s|| [%(levelname)s]: %(' + 'message)s', + datefmt='%Y-%m-%d %H:%M:%S', + stream=sys.stdout) + else: + + logging.basicConfig(level=log_levels[log_level], + format='%(asctime)s||User:%(' + 'user)s||%(funcName)s|| [%(levelname)s]: %(' + 'message)s', + datefmt='%Y-%m-%d %H:%M:%S', + filename=log_filename, + filemode='w') + logging.getLogger(name='matplotlib').setLevel(logging.CRITICAL) + common_logger = logging.getLogger(__name__) + f = cf() + common_logger.addFilter(f) + + return common_logger + + +def is_thresh_column(column_name: str) -> bool: + ''' + Determines if a column is a threshold column, i.e. cov_thresh, fcst_thresh, + or obs_thresh. + + Args: + + @param column_name: A string representation of the column name + + Returns: True if this column is a threshold column, False otherwise + ''' + + match = re.match(r'.*_thresh.*', column_name) + if match: + return True + else: + return False + + +def filter_by_fixed_vars(input_df: pd.DataFrame, settings_dict: dict) -> pd.DataFrame: + """ + Filter the input data based on values in the settings_dict dictionary. + For each key (corresponding to a column in the input_df dataframe), + create a query string. Use that query string to filter the input dataframe. + Repeat for all the keys and their corresponding values in the settings_dict. + + Use the pandas query() to perform database-like syntax for filtering the data: + col in ('a', 'b', '3', ...,'z') + + where col is the name of the key and the values in the parens represent + the values corresponding to that key. + + Since Python handles nan values in an unexpected way, if 'NA' is a value in the + list of values corresponding to a key, then different syntax will be required: + + col.isnull() | col in ('a', 'b', ..., 'z') + + Args: + input_df: The input dataframe to be subset. This is needed to check for + valid columns. + settings_dict: The dictionary representation of the settings in the YAML + configuration file + + Returns: + filtered_df: The filtered dataframe + """ + + # check if columns (keys) in the settings_dict exist in the dataframe before + # attempting to subset. If the settings_dict has keys that do not have + # corresponding column values in the dataframe, return the input dataframe. + valid_columns = [col for col in settings_dict if col in input_df.columns] + + if len(valid_columns) == 0: + print( + "No columns in data match what is requested for filtering by fixed variable. Input dataframe will be " + "returned") + return input_df + + # The pandas query method does not work as expected if + # one of the values in the list is 'NA'. When 'NA' is an element in the list + # use the col.isnull() syntax with the col in ('a', 'b', ..., 'z') syntax + # for the remaining values. + + # Create a query string for each column and save in a list + query_string_list = [] + + # Use an intermediate dataframe for filtering iteratively by column + filtered_df = input_df.copy(deep=True) + + for idx, col in enumerate(valid_columns): + # Variables for creating the query string + prev_val_string = "" + single_quote = "'" + list_sep = ", " + list_start = "(" + list_terminator = ")" + or_token = "| " + in_token = " in " + isnull_token = ".isnull()" + is_last_val = False + na_found = False + updated_vals = [] + + # Remove NA from the list of values and create a new + # list of values containing the remaining non-NA values. + values = settings_dict[col] + + # Check for incorrectly formatted fixed_vars_vals_input that is generated + # by the MVBatch.java: + # fixed_vars_vals_input: + # vx_mask: regionA + # + # the correct format: + # fixed_vars_vals_input: + # vx_mask: [regionA] + # + # OR + # + # fixed_vars_vals_input: + # vx_mask: + # - regionA + # + # + # Check if the value to the key (i.e. vx_mask, etc) is a string and convert it to a list + # i.e.: + # correct_value = [value] + # + # where value corresponds to regionA in example above + # + if type(values) is str: + values = [values] + + for val in values: + if val == 'NA': + na_found = True + else: + updated_vals.append(val) + + # Create the query string based on whether NA values exist. + if na_found: + if len(updated_vals) == 0: + # NA was the only value for this column, create the query + # then move onto the next column + prev_val_string = col + isnull_token + query_string_list.append(prev_val_string) + + else: + # At least one non-NA value in the list of values + prev_val_string = col + isnull_token + or_token + is_last_val = False + # Build remaining portion of the query (ie the col in ('a', 'b', + # 'c')) + for val_idx, val in enumerate(updated_vals): + # Identify when the last value in the list + # has been reached to avoid adding a ',' after + # the last value. + last_val = val_idx + 1 + + if last_val == len(updated_vals): + is_last_val = True + + # Create the 'col in' portion of the query + if val_idx == 0 and is_last_val: + # Both the first and last element in the list (i.e. list of one + # element) + prev_val_string = prev_val_string + col + in_token + \ + list_start + single_quote + val + \ + single_quote + list_terminator + + elif val_idx == 0 and (not is_last_val): + # First value of a list of values + prev_val_string = prev_val_string + col + in_token + \ + list_start + single_quote + val + \ + single_quote + list_sep + + + + elif val_idx > 0 and not is_last_val: + # One of the middle values in the list + query_string = prev_val_string + single_quote + val + \ + single_quote + list_sep + + else: + # The last value in the list + prev_val_string = prev_val_string + single_quote + val + \ + single_quote + list_terminator + + query_string_list.append(prev_val_string) + + + else: + + # No NA's found in values. Create the query: col in ('a', 'b', 'c') + prev_val_string = "" + is_last_val = False + + for val_idx, val in enumerate(updated_vals): + + # Identify when the last value in the list + # has been reached to avoid adding a ',' after + # the last value. + last_val = val_idx + 1 + + if last_val == len(updated_vals): + is_last_val = True + + # Only one value in the values list (both first and last element) + if val_idx == 0 and is_last_val: + prev_val_string = prev_val_string + col + in_token + list_start \ + + single_quote + val + single_quote + \ + list_terminator + + elif val_idx == 0 and (not is_last_val): + # First value of a list of values + prev_val_string = prev_val_string + col + in_token + \ + list_start + single_quote + val + \ + single_quote + list_sep + + elif val_idx > 0 and not is_last_val: + # One of the middle values in the list + prev_val_string = prev_val_string + single_quote + val + single_quote + list_sep + else: + # Last value in the list + prev_val_string = prev_val_string + single_quote + val + single_quote + list_terminator + + query_string_list.append(prev_val_string) + + # Perform query for each column (key) + for cur_query in query_string_list: + working_df = filtered_df.query(cur_query) + filtered_df = working_df.copy(deep=True) + + # clean up + del working_df + gc.collect() + + return filtered_df + + +def prepare_pct_roc(subset_df): + """ + Initialize the PCT ROC plot data, appends a beginning and end point + :param subset_df: PCT data + :return: PCT ROC plot data + """ + roc_df = pstats._calc_pct_roc(subset_df) + pody = roc_df['pody'] + pody = pd.concat([pd.Series([1]), pody], ignore_index=True) + pody = pd.concat([pody, pd.Series([0])]) + pofd = roc_df['pofd'] + pofd = pd.concat([pd.Series([1]), pofd], ignore_index=True) + pofd = pd.concat([pofd, pd.Series([0])], ignore_index=True) + thresh = roc_df['thresh'] + thresh = pd.concat([pd.Series(['']), thresh], ignore_index=True) + thresh = pd.concat([thresh, pd.Series([''])], ignore_index=True) + + return pody, pofd, thresh + + +def prepare_ctc_roc(subset_df, is_ascending): + """ + Initialize the CTC ROC plot data, appends a beginning and end point + :param subset_df: CTC data + :param is_ascending: thresh order + :return: CTC ROC plot data + """ + df_roc = cstats.calculate_ctc_roc(subset_df, ascending=is_ascending) + pody = df_roc['pody'] + pody = pd.concat([pd.Series([1]), pody], ignore_index=True) + pody = pd.concat([pody, pd.Series([0])], ignore_index=True) + pofd = df_roc['pofd'] + pofd = pd.concat([pd.Series([1]), pofd], ignore_index=True) + pofd = pd.concat([pofd, pd.Series([0])], ignore_index=True) + thresh = df_roc['thresh'] + thresh = pd.concat([pd.Series(['']), thresh], ignore_index=True) + thresh = pd.concat([thresh, pd.Series([''])], ignore_index=True) + + return pody, pofd, thresh + + +def strtobool(env_var:str)->bool: + """ + Since distutils.util.strtobool was deprecated in Python 3.12, implement + our own version. + + In the distutils.util.strtobool, a simple one line command was used to determine + whether an environment variable was set to True or False. In this + example, the default value is set to False in the event that the environment + variable is not defined: + + turn_on_logging = strtobool(os.getenv('LOG_BASE_PLOT', 'False') ) + + Environment variables can be set as string or bool. Evaluate whether a string + value for true or false (support case-insensitive text) is True/False and + set the default value. + + Args: + @parm env_vars: string name of the environment variable to evaluate + + turn_on_logging = strtobool(os.getenv('LOG_BASE_PLOT') ) + """ + + true_list = ['true', 't', '1',] + false_list = ['false', 'f', '0' ] + # if the environment variable does not exist, then return False + try: + val = os.environ[env_var] + except KeyError: + return False + + # If the environment variable is None, return false + if val is None: + return False + else: + # Check for variations of truth values + lower = val.lower() + if lower in true_list: + return True + elif lower in false_list: + return False + else: + msg = "Value does not represent a truth value (i.e. true or false)" + raise ValueError(msg) + + diff --git a/metplotpy/plots/wind_rose/wind_rose.py b/metplotpy/plots/wind_rose/wind_rose.py index 34e4c1f1f..cd386c42b 100644 --- a/metplotpy/plots/wind_rose/wind_rose.py +++ b/metplotpy/plots/wind_rose/wind_rose.py @@ -26,10 +26,10 @@ import plotly.graph_objects as go from plotly.subplots import make_subplots -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot from metplotpy.plots.wind_rose.wind_rose_config import WindRoseConfig -from metplotpy.plots.constants import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR -from metplotpy.plots import util +from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR +from metplotpy.plots import util_plotly as util class WindRosePlot(BasePlot): diff --git a/test/conftest.py b/test/conftest.py index 0a718653c..2316788b0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -113,13 +113,20 @@ def module_setup_env(request): This fixture automatically determines the test directory from the test module's location. """ - test_dir = request.fspath.dirname + test_dir = str(request.node.path.parent) print("Setting up environment") os.environ['TEST_DIR'] = test_dir + + # handle multiple test_*.py files in a single directory + # create a subdirectory named after the test file if it doesn't match the test directory + test_name = str(request.node.name).replace('test_', '').replace('.py', '') + if test_name != os.path.basename(test_dir): + test_name = os.path.join(os.path.basename(test_dir), test_name) + # write test output under METPLOTPY_TEST_OUTPUT if set, otherwise write to test/test_output # write to a subdirectory named after the plot type output_dir = os.environ.get('METPLOTPY_TEST_OUTPUT', os.path.join(test_dir, os.pardir)) - output_dir = os.path.join(output_dir, 'test_output', os.path.basename(test_dir)) + output_dir = os.path.join(output_dir, 'test_output', test_name) # remove output directory for plot type if it already exists to ensure clean test environment if os.path.exists(output_dir):