From 0e233516439501e8a1eff6630ec5cc8d3b9b4d71 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Wed, 21 Jan 2026 13:53:04 -0700 Subject: [PATCH 01/53] Issue #556 this is the original BasePlot class, containing Plotly and Matplotlib support --- metplotpy/plots/base_plot_plotly.py | 495 ++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 metplotpy/plots/base_plot_plotly.py diff --git a/metplotpy/plots/base_plot_plotly.py b/metplotpy/plots/base_plot_plotly.py new file mode 100644 index 00000000..03a84ef1 --- /dev/null +++ b/metplotpy/plots/base_plot_plotly.py @@ -0,0 +1,495 @@ +# ============================* + # ** 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 +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: + 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)) + if not os.path.exists(dirname): + os.mkdir(dirname) + 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) From df0dc9e375f6db94111e8af913d0f7d5f9303350 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:25:06 -0700 Subject: [PATCH 02/53] Per #556, create _plotly versions of util.py, constants.py, and config.py. Updated plots that use plotly to import from those versions so it is clear which plots still rely on plotly and need to be updated. This will also allow optional support of plotly for certain plots if we are not able to fully get rid of the plotly dependency in this development cycle. Also removed some unused imports. Replaced util.py function apply_weight_style with get_font_params since the existing version will not be able to be used with matplotlib --- metplotpy/plots/bar/bar.py | 4 +- metplotpy/plots/bar/bar_config.py | 6 +- metplotpy/plots/bar/bar_series.py | 2 +- metplotpy/plots/base_plot_plotly.py | 4 +- metplotpy/plots/box/box.py | 6 +- metplotpy/plots/box/box_config.py | 6 +- metplotpy/plots/box/box_series.py | 4 +- metplotpy/plots/config.py | 7 - metplotpy/plots/config_plotly.py | 897 ++++++++++++++++++ metplotpy/plots/constants.py | 4 +- metplotpy/plots/constants_plotly.py | 100 ++ metplotpy/plots/contour/contour.py | 6 +- metplotpy/plots/contour/contour_config.py | 6 +- metplotpy/plots/contour/contour_series.py | 5 +- metplotpy/plots/eclv/eclv.py | 6 +- metplotpy/plots/eclv/eclv_series.py | 5 +- metplotpy/plots/ens_ss/ens_ss.py | 4 +- metplotpy/plots/ens_ss/ens_ss_config.py | 6 +- metplotpy/plots/ens_ss/ens_ss_series.py | 5 +- .../equivalence_testing_bounds.py | 7 +- .../equivalence_testing_bounds_series.py | 8 +- metplotpy/plots/histogram/hist.py | 6 +- metplotpy/plots/histogram/hist_config.py | 6 +- metplotpy/plots/histogram/hist_series.py | 5 +- metplotpy/plots/histogram/histogram.py | 3 +- metplotpy/plots/histogram/prob_hist.py | 2 +- metplotpy/plots/histogram/rank_hist.py | 3 +- metplotpy/plots/histogram/rel_hist.py | 3 +- metplotpy/plots/histogram_2d/histogram_2d.py | 4 +- metplotpy/plots/line/line.py | 7 +- metplotpy/plots/line/line_config.py | 6 +- metplotpy/plots/line/line_series.py | 18 +- metplotpy/plots/mpr_plot/mpr_plot.py | 4 +- metplotpy/plots/polar_plot/polar_plot.py | 4 +- .../plots/reliability_diagram/reliability.py | 6 +- .../reliability_diagram/reliability_config.py | 6 +- metplotpy/plots/revision_box/revision_box.py | 6 +- .../plots/revision_box/revision_box_config.py | 6 +- .../plots/revision_series/revision_series.py | 7 +- .../revision_series/revision_series_config.py | 6 +- metplotpy/plots/roc_diagram/roc_diagram.py | 16 +- .../plots/roc_diagram/roc_diagram_config.py | 8 +- .../plots/roc_diagram/roc_diagram_series.py | 2 +- metplotpy/plots/scatter/scatter.py | 6 +- metplotpy/plots/scatter/scatter_config.py | 1 - metplotpy/plots/tcmpr_plots/box/tcmpr_box.py | 2 +- .../plots/tcmpr_plots/box/tcmpr_box_point.py | 2 +- .../plots/tcmpr_plots/box/tcmpr_point.py | 2 +- .../tcmpr_plots/line/mean/tcmpr_line_mean.py | 2 +- .../line/mean/tcmpr_series_line_mean.py | 2 +- .../line/median/tcmpr_line_median.py | 2 +- .../plots/tcmpr_plots/line/tcmpr_line.py | 2 +- .../plots/tcmpr_plots/rank/tcmpr_rank.py | 3 +- .../tcmpr_plots/relperf/tcmpr_relperf.py | 5 +- .../skill/mean/tcmpr_series_skill_mean.py | 2 +- .../skill/mean/tcmpr_skill_mean.py | 2 +- .../skill/median/tcmpr_skill_median.py | 2 +- .../plots/tcmpr_plots/skill/tcmpr_skill.py | 2 +- metplotpy/plots/tcmpr_plots/tcmpr.py | 10 +- metplotpy/plots/tcmpr_plots/tcmpr_config.py | 7 +- metplotpy/plots/tcmpr_plots/tcmpr_series.py | 2 +- metplotpy/plots/util.py | 82 +- metplotpy/plots/util_plotly.py | 698 ++++++++++++++ metplotpy/plots/wind_rose/wind_rose.py | 4 +- 64 files changed, 1858 insertions(+), 214 deletions(-) create mode 100644 metplotpy/plots/config_plotly.py create mode 100644 metplotpy/plots/constants_plotly.py create mode 100644 metplotpy/plots/util_plotly.py diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index cb881886..f62dd456 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -23,10 +23,10 @@ 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.base_plot_plotly import BasePlot from metplotpy.plots.constants 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 5a6436ac..091d1214 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 f455bd15..aa73bd23 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_plotly.py b/metplotpy/plots/base_plot_plotly.py index 03a84ef1..ccb915ed 100644 --- a/metplotpy/plots/base_plot_plotly.py +++ b/metplotpy/plots/base_plot_plotly.py @@ -21,8 +21,8 @@ import yaml from typing import Union import kaleido -import metplotpy.plots.util -from metplotpy.plots.util import strtobool + +from metplotpy.plots.util_plotly import strtobool from .config import Config from metplotpy.plots.context_filter import ContextFilter diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 6c41762a..e798fc39 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 410fc8c3..a576ef65 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 9546be9a..a0434f3a 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 9f1f9f7f..d35dc70e 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -881,13 +881,6 @@ def _get_lines(self) -> Union[list, None]: # 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']) diff --git a/metplotpy/plots/config_plotly.py b/metplotpy/plots/config_plotly.py new file mode 100644 index 00000000..874d20be --- /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 2c8fb35c..0717fe4d 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -79,10 +79,10 @@ '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} +# approximated from plotly marker size to matplotlib marker size +PCH_TO_MATPLOTLIB_MARKER_SIZE = {'.': 14, 'o': 36, 's': 20, '^': 36, 'd': 20, 'H': 28} 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} diff --git a/metplotpy/plots/constants_plotly.py b/metplotpy/plots/constants_plotly.py new file mode 100644 index 00000000..2c8fb35c --- /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 353a1d72..b12cdb66 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 6d28d4c7..4336c1e3 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 024bd3de..5f55acc6 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 5860b572..446e4e2a 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 94433a3a..0c34becf 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 d6d153ee..2cbf9ad9 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -29,8 +29,8 @@ from metplotpy.plots.constants 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 a1939721..298428c5 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 508a6e51..28d599c6 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 5d3798c0..957db07f 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 1548dc9c..f91aa8be 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 a3012a5c..b504e3aa 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 16945ab6..5016e347 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 38335ba2..1e30222d 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 231d333f..4ef52dea 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 d4cd7aae..c9e2ba57 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 a9a054b9..0a54ee6f 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 3040451e..5035fcd1 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 3f22aa06..4dff7b3d 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 c49c3614..dd71a617 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 d4acbe88..d1031846 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 04c644e4..cea0dd7a 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 329d5b27..391169ed 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.base_plot_plotly import BasePlot from metplotpy.plots.constants 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/polar_plot/polar_plot.py b/metplotpy/plots/polar_plot/polar_plot.py index 4b5a9f99..0703d1e0 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 b83f9572..bb40daba 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 d011c054..3f1175d1 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 d9336712..8dd029ff 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 8f74d50f..873100a1 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 acf7621e..ea015463 100644 --- a/metplotpy/plots/revision_series/revision_series.py +++ b/metplotpy/plots/revision_series/revision_series.py @@ -17,16 +17,15 @@ from typing import Union -import yaml import numpy as np 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 d031be4f..f7e5a0d8 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 c728e0c3..1832faa7 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 3f11e0b8..a2ca8a01 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 9d3f59c2..81be29fe 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 2f13b588..3abaead7 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 0e5c5665..817c7c95 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. diff --git a/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py b/metplotpy/plots/tcmpr_plots/box/tcmpr_box.py index ec086111..f4367821 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 bff7b448..f39de279 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 44cab41b..c4488999 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 e73a9608..20c6fea5 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 cc013ca6..fc533388 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 a5cced49..4352c990 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 f759e839..b43593cd 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 8d348dcc..49dcb265 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 f6c35089..377121db 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 d7632c17..06de9ce4 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 1f11bffc..500c7f50 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 cc0d9406..bdd2872d 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 795dbe7e..ef6d6797 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 9988e064..32d92684 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 @@ -585,7 +585,7 @@ def create_plot(config_obj: dict) -> None: quotechar='"', skipinitialspace=True, encoding='utf-8') logger = util.get_common_logger(config_obj.log_level, config_obj.log_filename) -\ + for plot_type in config_obj.plot_type_list: # Apply event equalization, if requested diff --git a/metplotpy/plots/tcmpr_plots/tcmpr_config.py b/metplotpy/plots/tcmpr_plots/tcmpr_config.py index 0d75e358..7fc6de59 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 3d88bfef..f3dcab37 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 ad7c9954..c219edc4 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 @@ -98,8 +99,6 @@ def make_plot(config_filename, plot_class): 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 @@ -126,26 +125,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,36 +198,24 @@ 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: +def add_horizontal_line(y: float, line_properties: dict) -> None: + """Adds a horizontal line to the matplotlib plot + + @param y y value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None """ - figure.add_shape( - type='line', - yref='y', y0=y, y1=y, - xref='paper', x0=0, x1=1, - line=line_properties, - ) + plt.axhline(y=y, xmin=0, xmax=1, **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: +def add_vertical_line(x: float, line_properties: dict) -> None: + """Adds a vertical line to the matplotlib plot + + @param x x value for the line + @param line_properties dictionary with line properties like color, width, dash + @returns None """ - figure.add_shape( - type='line', - yref='paper', y0=0, y1=1, - xref='x', x0=x, x1=x, - line=line_properties, - ) + plt.axvline(x=x, ymin=0, ymax=1, **line_properties) def abline(x_value: float, intercept: float, slope: float) -> float: diff --git a/metplotpy/plots/util_plotly.py b/metplotpy/plots/util_plotly.py new file mode 100644 index 00000000..ad7c9954 --- /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 fea5d568..ca6a7cff 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 import util_plotly as util class WindRosePlot(BasePlot): From 1a38b17f5abc997f4462e5c2beebd81f4fffd6db Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 22 Jan 2026 09:40:53 -0700 Subject: [PATCH 03/53] Do not use util.apply_weight_style -- it adds html which isn't used by matplotlib. Instead set xaxis label weight similar to taylor_diagram logic --- metplotpy/plots/scatter/scatter_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/scatter/scatter_config.py b/metplotpy/plots/scatter/scatter_config.py index 817c7c95..1d471ef3 100644 --- a/metplotpy/plots/scatter/scatter_config.py +++ b/metplotpy/plots/scatter/scatter_config.py @@ -98,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() From ceb58021d3a251d942d6e0ed2b786d6a030b7ac0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:46:16 -0700 Subject: [PATCH 04/53] add fix for creating parent directories to plotly version of base plot --- metplotpy/plots/base_plot_plotly.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/metplotpy/plots/base_plot_plotly.py b/metplotpy/plots/base_plot_plotly.py index ccb915ed..197720e9 100644 --- a/metplotpy/plots/base_plot_plotly.py +++ b/metplotpy/plots/base_plot_plotly.py @@ -387,8 +387,7 @@ def save_to_file(self): # Create the directory for the output plot if it doesn't already exist dirname = os.path.dirname(os.path.abspath(image_name)) - if not os.path.exists(dirname): - os.mkdir(dirname) + os.makedirs(dirname, exist_ok=True) if self.figure: try: self.figure.write_image(image_name) From 69d5b386a33472f059adadb1534d525f12e8c24b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:33:08 -0700 Subject: [PATCH 05/53] hotfix: fix handling of missing data by changing replace value from string 9999 to integer 9999 and use np.nan instead of string 'NA' --- metplotpy/plots/skew_t/skew_t.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplotpy/plots/skew_t/skew_t.py b/metplotpy/plots/skew_t/skew_t.py index 1c171f57..e6713117 100644 --- a/metplotpy/plots/skew_t/skew_t.py +++ b/metplotpy/plots/skew_t/skew_t.py @@ -67,7 +67,7 @@ def extract_sounding_data(input_file, output_directory): # Read in the current sounding data file, replacing any 9999 values with NaN. df_raw: pandas.DataFrame = pd.read_csv(sounding_data_file, sep=r'\s+', skiprows=1, engine='python') - df_raw.replace('9999', 'NA', inplace=True) + df_raw.replace(9999, np.nan, inplace=True) # Rename some columns so they are more descriptive df: pandas.DataFrame = df_raw.rename(columns={'TIME': 'FIELD', '(HR)': 'UNITS'}) From 150a3c77b2e0acf4260eefd9c7d6ca31bd49c0de Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:33:51 -0700 Subject: [PATCH 06/53] hotfix: remove string representing color that causes UserWarning and causes yaml configurations for lines to be ignored --- metplotpy/plots/skew_t/skew_t.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/skew_t/skew_t.py b/metplotpy/plots/skew_t/skew_t.py index e6713117..2402ffc4 100644 --- a/metplotpy/plots/skew_t/skew_t.py +++ b/metplotpy/plots/skew_t/skew_t.py @@ -590,14 +590,14 @@ def create_skew_t(input_file: str, config: dict, logger: logging) -> None: temp_linewidth = config['temp_line_thickness'] temp_linestyle = config['temp_line_style'] temp_linecolor = config['temp_line_color'] - skew.plot(pressure, temperature, 'r', linewidth=temp_linewidth, + skew.plot(pressure, temperature, linewidth=temp_linewidth, linestyle=temp_linestyle, color=temp_linecolor) dewpt_linewidth = config['dewpt_line_thickness'] dewpt_linestyle = config['dewpt_line_style'] dewpt_linecolor = config['dewpt_line_color'] logger.info(f"Generate the dew point line for {cur_time} hour") - skew.plot(pressure, dew_pt, 'g', linewidth=dewpt_linewidth, + skew.plot(pressure, dew_pt, linewidth=dewpt_linewidth, linestyle=dewpt_linestyle, color=dewpt_linecolor) # Adiabat and mixing lines. From b6647403df78c9158dc6ab878d9bc8045c5d7ab0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 5 Feb 2026 11:36:06 -0700 Subject: [PATCH 07/53] remove more plotly-specific stuff from matplotlib version --- metplotpy/plots/base_plot.py | 46 +++--------------------------------- 1 file changed, 3 insertions(+), 43 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 6925ce64..5048ff62 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -20,27 +20,15 @@ import numpy as np 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 +37,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 @@ -414,21 +389,6 @@ 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: - - 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 From 07cf5bd48ecbb2af3cd4ebdad11411fb62dae87e Mon Sep 17 00:00:00 2001 From: bikegeek Date: Thu, 5 Feb 2026 13:29:29 -0700 Subject: [PATCH 08/53] Issue #556 Remove any Plotly-specific code and imports. Update copyright date and information. --- metplotpy/plots/base_plot.py | 147 +---------------------------------- 1 file changed, 2 insertions(+), 145 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 5048ff62..8a088f38 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 """ @@ -20,11 +19,8 @@ import numpy as np import yaml from typing import Union - -import metplotpy.plots.util from metplotpy.plots.util import strtobool from .config import Config -from metplotpy.plots.context_filter import ContextFilter turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime @@ -114,31 +110,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): """ @@ -174,121 +146,6 @@ def get_legend_style(self): 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. From b616fcd6d5e04259f5aab8f4e00a4f259d2736dd Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:11:06 -0700 Subject: [PATCH 09/53] clean up logic to calculate plot dimensions to always use matplotlib units. added helper function to reduce duplication for logic to convert units --- metplotpy/plots/config.py | 68 ++++++------------- .../performance_diagram_config.py | 4 +- .../taylor_diagram/taylor_diagram_config.py | 4 +- 3 files changed, 24 insertions(+), 52 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 7f21dbbf..95a98a9e 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') @@ -656,17 +653,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 +663,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 * 0.1 * constants.MM_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,55 +773,27 @@ 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. 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 self._convert_units_to_inches(value2convert, units) - return converted_value def _get_bool(self, param: str) -> Union[bool, None]: """ diff --git a/metplotpy/plots/performance_diagram/performance_diagram_config.py b/metplotpy/plots/performance_diagram/performance_diagram_config.py index 263227f8..1863b985 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/taylor_diagram/taylor_diagram_config.py b/metplotpy/plots/taylor_diagram/taylor_diagram_config.py index fd556cb4..c7696cfa 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') From 3bc22fd0e71a2191768ee2337d1c382562333f6e Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:46:08 -0700 Subject: [PATCH 10/53] remove plotly-specific variables and update incorrect imports to use plotly version for now --- metplotpy/plots/bar/bar.py | 2 +- metplotpy/plots/constants.py | 16 ---------------- metplotpy/plots/ens_ss/ens_ss.py | 2 +- metplotpy/plots/mpr_plot/mpr_plot.py | 2 +- metplotpy/plots/wind_rose/wind_rose.py | 2 +- 5 files changed, 4 insertions(+), 20 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index f7e8507a..771ffbf4 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -27,7 +27,7 @@ from metplotpy.plots.bar.bar_config import BarConfig from metplotpy.plots.bar.bar_series import BarSeries from metplotpy.plots.base_plot_plotly import BasePlot -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 diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 0717fe4d..4e2f3755 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -62,34 +62,18 @@ 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} -TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} 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 diff --git a/metplotpy/plots/ens_ss/ens_ss.py b/metplotpy/plots/ens_ss/ens_ss.py index 06b0dc8c..a037ab29 100644 --- a/metplotpy/plots/ens_ss/ens_ss.py +++ b/metplotpy/plots/ens_ss/ens_ss.py @@ -26,7 +26,7 @@ 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_plotly import BasePlot diff --git a/metplotpy/plots/mpr_plot/mpr_plot.py b/metplotpy/plots/mpr_plot/mpr_plot.py index 0398876e..5cfbeb0a 100644 --- a/metplotpy/plots/mpr_plot/mpr_plot.py +++ b/metplotpy/plots/mpr_plot/mpr_plot.py @@ -24,7 +24,7 @@ import plotly.io as pio from metplotpy.plots.base_plot_plotly import BasePlot -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.mpr_plot.mpr_plot_config import MprPlotConfig from metplotpy.plots.wind_rose.wind_rose import WindRosePlot diff --git a/metplotpy/plots/wind_rose/wind_rose.py b/metplotpy/plots/wind_rose/wind_rose.py index 88ae596b..cd386c42 100644 --- a/metplotpy/plots/wind_rose/wind_rose.py +++ b/metplotpy/plots/wind_rose/wind_rose.py @@ -28,7 +28,7 @@ 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.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, PLOTLY_PAPER_BGCOOR from metplotpy.plots import util_plotly as util From fda41c53205061fc4ed7f871734de1e6d901b856 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:46:26 -0700 Subject: [PATCH 11/53] resolve SonarQube complaints and clean up logic --- metplotpy/plots/base_plot.py | 117 ++++++++++++++++++----------------- metplotpy/plots/config.py | 2 +- metplotpy/plots/constants.py | 1 + 3 files changed, 61 insertions(+), 59 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 8a088f38..e9a18539 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -107,7 +107,7 @@ def get_image_format(self): return strings[-1] # print the message if invalid and return default - print('Unrecognised image format. png will be used') + print(f'Unrecognised image format. {self.DEFAULT_IMAGE_FORMAT} will be used') return self.DEFAULT_IMAGE_FORMAT @@ -124,12 +124,10 @@ 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 - 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: @@ -138,11 +136,15 @@ 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 @@ -224,17 +226,11 @@ def save_to_file(self): 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}") - + self.logger.error(f"FileNotFoundError: Cannot save to file {image_name}") except ValueError as ex: - self.logger.error(f"ValueError: Could not save output file.") + self.logger.error(f"ValueError: Could not save output file. {ex}") else: - self.logger.error(f"The figure {dirname} cannot be saved.") + self.logger.error(f"The figure {image_name} cannot be saved.") print("Oops! The figure was not created. Can't save.") def remove_file(self): @@ -254,46 +250,51 @@ def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = Non @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) + if not hasattr(config_obj, 'lines') or config_obj.lines is None: + return + + shapes = [] + for line in config_obj.lines: + # draw horizontal line + if line['type'] == 'horiz_line': + shapes.append({ + '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({ + '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"] + msg = f"Vertical line with position {line_position} cannot be created." + self.logger.warning(msg) + print(msg) + # ignore everything else + + # draw lines + self.figure.update_layout(shapes=shapes) @staticmethod def get_array_dimensions(data): diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 95a98a9e..821ae9a3 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -668,7 +668,7 @@ def _convert_units_to_inches(self, value, units): if units_lower == 'mm': return value * constants.MM_TO_INCHES if units_lower == 'cm': - return value * 0.1 * constants.MM_TO_INCHES + return value * constants.CM_TO_INCHES # if unsupported units are specified, log a warning but assume inches if units_lower != 'in': diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 4e2f3755..68e992ab 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 # ':' ... From 392ee95e5a2f4a9c7e18b98f6dc3bf498aca9bfc Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:08:33 -0700 Subject: [PATCH 12/53] more SonarQube issues resolved --- metplotpy/plots/config.py | 162 ++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 77 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 821ae9a3..f06df04a 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -105,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') @@ -322,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 @@ -443,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) @@ -557,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 @@ -566,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 @@ -612,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): @@ -632,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 @@ -829,34 +836,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_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 From 0ab752812fd269c7642f2c935debd494d5ae7eff Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 16:58:36 -0700 Subject: [PATCH 13/53] Issue #556 Updates to base_plot to support title,caption, x-axis label and y-axis label style, weight, and font size. Moved the add_horizontal_line() and add_vertical_line() code from the util.py module to this module as this will be needed for all plot types. TODO comments are used to denote code that will need to be removed when all plot types have migratee to Matplotlib. --- metplotpy/plots/base_plot.py | 87 ++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 8a088f38..08586724 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -17,11 +17,13 @@ import logging import warnings import numpy as np +from matplotlib.font_manager import FontProperties import yaml from typing import Union from metplotpy.plots.util import strtobool from .config import Config + turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime if turn_on_logging: @@ -146,6 +148,57 @@ def get_legend_style(self): return legend_settings + def get_weights_size_styles(self): + """ + 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. + + Returns: + weights_size_styles: A dictionary containing the font property information + for the title, captions, x-axis label, and y-axis label + """ + 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 + + def get_config_value(self, *args): """Gets the value of a configuration parameter. @@ -203,6 +256,7 @@ def get_img_bytes(self): return None + # TODO Plotly-specific method, NOT needed for Matplotlib def save_to_file(self): """Saves the image to a file specified in the config file. Prints a message if fails @@ -214,8 +268,9 @@ def save_to_file(self): """ 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) + # Catch deprecation warnings from third-party packages as + # errors and log the message. + warnings.filterwarnings("error", category=DeprecationWarning) # Create the directory for the output plot if it doesn't already exist dirname = os.path.dirname(os.path.abspath(image_name)) @@ -227,9 +282,11 @@ def save_to_file(self): 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" + except ResourceWarning as rw: + self.logger.warning(f"ResourceWarning {rw}: in " f" {image_name}") + except DeprecationWarning as dw: + self.logger.warning(f"DeprecationWarning {dw} in: {image_name}") except ValueError as ex: self.logger.error(f"ValueError: Could not save output file.") @@ -246,6 +303,8 @@ def remove_file(self): if image_name is not None and os.path.exists(image_name): os.remove(image_name) +# TODO Remove Plotly specific, use add_horizontal_line() and add_vertical_line() below +# Plotly-specific, 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 @@ -295,6 +354,26 @@ def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = Non # draw lines self.figure.update_layout(shapes=shapes) + def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + """Adds a horizontal line to the matplotlib plot + + @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) + + def add_vertical_line(plt, x: float, line_properties: dict) -> None: + """Adds a vertical line to the matplotlib plot + + @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 + """ + plt.axvline(x=x, ymin=0, ymax=1, **line_properties) + @staticmethod def get_array_dimensions(data): """Returns the dimension of the array From 14469fb6707ae39349586175b2727697ef3a8e77 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:22:09 -0700 Subject: [PATCH 14/53] Issue #556 use the get_weights_size_style() from base_plot.py to plot title and caption --- .../plots/taylor_diagram/taylor_diagram.py | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram.py b/metplotpy/plots/taylor_diagram/taylor_diagram.py index b6b7bc54..77882284 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram.py @@ -279,34 +279,26 @@ 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 + caption = wts_size_styles['caption'] + + plt.figtext( + self.config_obj.caption_align, self.config_obj.caption_offset, + self.config_obj.plot_caption, + fontproperties=caption, color=self.config_obj.caption_color + ) # Add a figure legend @@ -331,6 +323,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. From ad3c90f46238c67b190d4b9867511d126f5e17c2 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:32:21 -0700 Subject: [PATCH 15/53] Fixed comment to remove Plotly reference in --- metplotpy/plots/config.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index f06df04a..8863c279 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -780,7 +780,7 @@ 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) -> int: + 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. METviewer accepts units of inches or mm for width and @@ -790,14 +790,29 @@ def calculate_plot_dimension(self, config_value: str) -> int: 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. 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 + 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) return self._convert_units_to_inches(value2convert, units) From 1c0d319e440ddfa6a459850037f538dcfaff1409 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:48:40 -0700 Subject: [PATCH 16/53] Revert to previous version, which already removed Plotly-specific code in calculate_plot_dimension --- metplotpy/plots/config.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 8863c279..a1dec79f 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -56,7 +56,7 @@ def __init__(self, parameters): # 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_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') @@ -780,7 +780,7 @@ 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. METviewer accepts units of inches or mm for width and @@ -798,21 +798,8 @@ def calculate_plot_dimension(self, config_value: str, output_units:str) -> int: ''' 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 - 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) return self._convert_units_to_inches(value2convert, units) From 6f68ae8ecf7d5849338da8bd24ee3f7308f31a60 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:50:02 -0700 Subject: [PATCH 17/53] Added TODO for code that should be removed when all plots have been migrated to Matplotlib --- metplotpy/plots/util.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index c219edc4..7ec21b3e 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -24,6 +24,7 @@ from typing import Union import pandas as pd import matplotlib.pyplot as plt +from jinja2.lexer import TOKEN_DOT from metplotpy.plots.context_filter import ContextFilter as cf import metcalcpy.util.pstd_statistics as pstats @@ -86,6 +87,10 @@ def get_params(config_filename): return parse_config(config_file) + +# TODO Remove, Plotly specific +# Matplotlib only needs to do a plt.savefig() +# command def make_plot(config_filename, plot_class): """!Get plot parameters and create the plot. @@ -99,7 +104,7 @@ def make_plot(config_filename, plot_class): try: plot = plot_class(params) plot.save_to_file() - plot.write_html() + # 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()}") @@ -198,6 +203,7 @@ def pretty(low, high, number_of_intervals) -> Union[np.ndarray, list]: return np.arange(miny, maxy + 0.5 * d, d) +# TODO remove, moved to base_plot.py def add_horizontal_line(y: float, line_properties: dict) -> None: """Adds a horizontal line to the matplotlib plot @@ -208,6 +214,7 @@ def add_horizontal_line(y: float, line_properties: dict) -> None: plt.axhline(y=y, xmin=0, xmax=1, **line_properties) +# TODO remove, moved to base_plot.py def add_vertical_line(x: float, line_properties: dict) -> None: """Adds a vertical line to the matplotlib plot From f92761a241d1cfdaac84c927c6e46d28414ced10 Mon Sep 17 00:00:00 2001 From: bikegeek Date: Fri, 6 Feb 2026 17:50:52 -0700 Subject: [PATCH 18/53] Added TODO comments to identify code that will need to be removed when all plots have migrated to Matplotlib --- metplotpy/plots/constants.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 68e992ab..91542257 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -64,6 +64,12 @@ AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] +# TODO Remove, Plotly specific +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', @@ -75,6 +81,30 @@ XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} +# TODO REMOVE PLOTLY-specific +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} + +# TODO Remove, Plotly specific +TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} + +# used for tick angles +XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} +YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} + +# TODO Remove these three lines, Plotly specific +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 From b7e827d963889bfd60d1cfe0ec2fe0914496826d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:11:07 -0700 Subject: [PATCH 19/53] remove plotly variables from matplotlib version --- metplotpy/plots/constants.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 91542257..8e822b53 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -64,12 +64,6 @@ AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] -# TODO Remove, Plotly specific -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', @@ -81,30 +75,13 @@ XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -# TODO REMOVE PLOTLY-specific -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} -# TODO Remove, Plotly specific -TYPE_TO_PLOTLY_MODE = {'b': 'lines+markers', 'p': 'markers', 'l': 'lines'} - # used for tick angles XAXIS_ORIENTATION = {0: 0, 1: 0, 2: 270, 3: 270} YAXIS_ORIENTATION = {0: -90, 1: 0, 2: 0, 3: -90} -# TODO Remove these three lines, Plotly specific -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 From 4467a905a567e8b0450fd164a6dce1edf14b5905 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:11:16 -0700 Subject: [PATCH 20/53] remove unneeded import --- metplotpy/plots/util.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index 7ec21b3e..29e3dd2a 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -24,7 +24,6 @@ from typing import Union import pandas as pd import matplotlib.pyplot as plt -from jinja2.lexer import TOKEN_DOT from metplotpy.plots.context_filter import ContextFilter as cf import metcalcpy.util.pstd_statistics as pstats From f10a8a062d5ca595bd3e992797e860b06fb83f5b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:10:00 -0700 Subject: [PATCH 21/53] Per #558, refactor bar plot to use matplotlib instead of plotly --- metplotpy/plots/bar/bar.py | 353 +++++++++--------------------- metplotpy/plots/bar/bar_config.py | 77 +++---- metplotpy/plots/bar/bar_series.py | 6 +- metplotpy/plots/base_plot.py | 31 ++- metplotpy/plots/config.py | 12 +- metplotpy/plots/constants.py | 6 +- test/bar/bar_with_nones.yaml | 2 +- test/bar/custom_bar.yaml | 6 +- test/bar/threshold_bar.yaml | 2 +- 9 files changed, 184 insertions(+), 311 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 771ffbf4..5a5d9fe4 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -17,18 +17,18 @@ import re from operator import add +import numpy as np import pandas as pd -import plotly.graph_objects as go -from plotly.graph_objects import Figure -from plotly.subplots import make_subplots +from matplotlib import pyplot as plt + +from matplotlib.font_manager import FontProperties import metcalcpy.util.utils as calc_util -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util +from metplotpy.plots import constants from metplotpy.plots.bar.bar_config import BarConfig from metplotpy.plots.bar.bar_series import BarSeries -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.base_plot import BasePlot class Bar(BasePlot): @@ -57,7 +57,7 @@ def __init__(self, parameters: dict) -> None: # Check that we have all the necessary settings for each series self.logger.info("Consistency checking of config settings for colors, " "legends, etc.") - is_config_consistent = self.config_obj._config_consistency_check() + is_config_consistent = self.config_obj.config_consistency_check() if not is_config_consistent: value_error_msg = ("ValueError: The number of series defined by series_val_1 and " "derived curves is inconsistent with the number of " @@ -159,50 +159,54 @@ def _create_series(self, input_data): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) + if self.config_obj.xaxis_reverse: + series_list.reverse() + return series_list def _create_figure(self): """ Create a bar plot from defaults and custom parameters """ + self._n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + self._group_width = 0.8 # matplotlib default + # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) + + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) + n_stats = self._add_series(ax) + self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) + + self._add_legend(ax) + + plt.tight_layout() + + def _add_series(self, ax): # placeholder for the number of stats n_stats = [0] * len(self.config_obj.indy_vals) - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() - # add series lines - for series in self.series_list: + for idx, series in enumerate(self.series_list): # Don't generate the plot for this series if # it isn't requested (as set in the config file) if series.plot_disp: - self._draw_series(series) + self._draw_series(ax, series, idx) # aggregate number of stats n_stats = list(map(add, n_stats, series.series_points['nstat'])) - # add custom lines - if len(self.series_list) > 0: - self._add_lines( - self.config_obj, - sorted( - self.series_list[0].series_data[self.config_obj.indy_var].unique()) - ) + return n_stats - # apply y axis limits - self._yaxis_limits() - - # add x2 axis - self._add_x2axis(n_stats) - - def _draw_series(self, series: BarSeries) -> None: + def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: """ Draws the formatted Bar on the plot :param series: Bar series object with data and parameters @@ -210,7 +214,8 @@ def _draw_series(self, series: BarSeries) -> None: y_points = series.series_points['dbl_med'] is_threshold, is_percent_threshold = util.is_threshold_value( - series.series_data[self.config_obj.indy_var]) + series.series_data[self.config_obj.indy_var] + ) # If there are any None types in the series_points['dbl_med'] list, then use the # indy_vals defined in the config file to ensure that the number of y_points @@ -218,6 +223,7 @@ def _draw_series(self, series: BarSeries) -> None: # same number of x_points. if None in y_points: x_points = self.config_obj.indy_vals + y_points = [item if item is not None else 0 for item in y_points] elif is_percent_threshold: x_points = self.config_obj.indy_var elif is_threshold: @@ -233,161 +239,78 @@ def _draw_series(self, series: BarSeries) -> None: else: x_points = sorted(series.series_data[self.config_obj.indy_var].unique()) - # add the plot - self.figure.add_trace( - go.Bar( - x=x_points, - y=y_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - name=self.config_obj.user_legends[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx] - ) - ) - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters[ - 'caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters[ - 'title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout - fig = make_subplots(specs=[[{"secondary_y": False}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - fig.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': self.config_obj.indy_label, - } - ) + base = np.arange(len(x_points)) + n = max(self._n_visible_series, 1) + width = self._group_width / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset - return fig + # add the plot + ax.bar(x=x_locs, height=y_points, width=width, align='center', color=self.config_obj.colors_list[series.idx], + label=self.config_obj.user_legends[series.idx]) - def _add_xaxis(self) -> None: + def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: """ Configures and adds x-axis to the plot """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - type='category' - ) - # reverse xaxis if needed + ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) + xtick_locs = np.arange(len(self.config_obj.indy_label)) + ax.set_xticks(xtick_locs, self.config_obj.indy_label) + ax.tick_params(axis="x", direction="in", which="both", labelrotation=self.config_obj.x_tickangle) + if self.config_obj.grid_on: + ax.grid(True, which='major', axis='x', color=self.config_obj.blended_grid_col, + linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) + ax.set_axisbelow(True) + if self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") + ax.invert_xaxis() - def _add_yaxis(self) -> None: + def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: """ Configures and adds y-axis to the plot """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters[ - 'ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - - def _add_legend(self) -> None: + ax.set_ylabel(self.config_obj.yaxis_1, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['ylab_offset']) * constants.PIXELS_TO_POINTS) + ax.tick_params(axis="y", direction="in", which="both", labelrotation=self.config_obj.y_tickangle) + + # set y limits if defined + if len(self.config_obj.parameters['ylim']) > 0: + ax.set_ylim(self.config_obj.parameters['ylim']) + + # add grid lines if requested + if self.config_obj.grid_on: + ax.grid(True, which='major', axis='y', color=self.config_obj.blended_grid_col, linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) + ax.set_axisbelow(True) + + def _add_legend(self, ax: plt.Axes) -> None: """ Creates a plot legend based on the properties from the config file and attaches it to the initial Figure """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': - self.config_obj.legend_border_color, - 'borderwidth': - self.config_obj.legend_border_width, - 'orientation': - self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - - def _yaxis_limits(self) -> None: - """ - Apply limits on y axis if needed - """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout( - yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) + orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" + + handles, labels = ax.get_legend_handles_labels() + if not handles: + print("Warning: No labels found. Use ax.plot(..., label='name')") + + legend = ax.legend( + handles=handles, + labels=labels, + bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), + loc='upper center', + edgecolor=self.config_obj.legend_border_color, + frameon=True, + ncol=max(1, len(handles)) if orientation == "horizontal" else 1, + fontsize=self.config_obj.legend_size, + labelcolor="black" + ) + if legend: + frame = legend.get_frame() + frame.set_linewidth(self.config_obj.legend_border_width) + - def _add_x2axis(self, n_stats) -> None: + def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: """ Creates x2axis based on the properties from the config file and attaches it to the initial Figure @@ -395,82 +318,14 @@ def _add_x2axis(self, n_stats) -> None: :param n_stats: - labels for the axis """ if self.config_obj.show_nstats: - self.figure.update_layout(xaxis2={'title_text': - util.apply_weight_style('NStats', - self.config_obj.parameters[ - 'x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': - self.config_obj.x2_title_font_size - }, - 'title_standoff': abs( - self.config_obj.parameters[ - 'x2lab_offset'] - ), - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': - self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x' - } - ) - # reverse x2axis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_layout(xaxis2={'autorange': "reversed"}) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(self.config_obj.indy_vals), - x=self.config_obj.indy_vals, - xaxis='x2', showlegend=False) - ) - - def remove_file(self): - """ - Removes previously made image file . Invoked by the parent class before - self.output_file - attribute can be created, but overridden here. - """ - - super().remove_file() - self._remove_html() - - def _remove_html(self) -> None: - """ - Removes previously made HTML file. - """ - - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # remove the old file if it exist - if os.path.exists(html_name): - os.remove(html_name) - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" + ax_top = ax.secondary_xaxis('top') + ax_top.set_xlabel('NStats', fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) + current_locs = ax.get_xticks() + ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) + # this doesn't appear to be working to add ticks at the top + ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) - # save html - self.figure.write_html(html_name, include_plotlyjs=False) def write_output_file(self) -> None: """ @@ -504,6 +359,10 @@ def write_output_file(self) -> None: for series in self.series_list: f.write(f"{series.series_points['dbl_med']}\n") + def save_to_file(self) -> None: + image_name = self.get_config_value('plot_filename') + os.makedirs(os.path.dirname(image_name), exist_ok=True) + plt.savefig(image_name, dpi=self.get_config_value('plot_res')) def main(config_filename=None): """ diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 091d1214..d5b75b39 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -17,9 +17,8 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants as constants import metcalcpy.util.utils as utils @@ -38,6 +37,8 @@ def __init__(self, parameters: dict) -> None: """ super().__init__(parameters) + self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] + # Optional setting, indicates *where* to save the dump_points_1 file # used by METviewer self.points_path = self.get_config_value('points_path') @@ -53,12 +54,12 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET + self.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -76,7 +77,6 @@ def __init__(self, parameters: dict) -> None: 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']) ############################################## # x2-axis parameters @@ -138,26 +138,6 @@ def _get_plot_disp(self) -> list: return self.create_list_by_series_ordering(plot_display_bools) - def _get_fcst_vars(self, index): - """ - 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. - - """ - - fcst_var_val_dict = self.get_config_value('fcst_var_val_1') - if not fcst_var_val_dict: - fcst_var_val_dict = {} - - return fcst_var_val_dict - def _get_plot_stat(self) -> str: """ Retrieves the plot_stat setting from the config file. @@ -183,7 +163,7 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def _config_consistency_check(self) -> bool: + def config_consistency_check(self) -> bool: """ Checks that the number of settings defined for plot_ci, plot_disp, series_order, user_legend colors, and series_symbols @@ -198,21 +178,23 @@ def _config_consistency_check(self) -> bool: and vx_mask defined in the series_val_1 setting) """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends : - status = True + lists_to_check = { + "plot_disp": self.plot_disp, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + } + status = True + for name, list_to_check in lists_to_check.items(): + + if len(list_to_check) == self.num_series: + continue + + self.logger.error( + f"{name} ({len(list_to_check)}) does not match number of series ({self.num_series})" + ) + status = False + return status def _get_user_legends(self, legend_label_type: str = '') -> list: @@ -267,8 +249,8 @@ def get_series_y(self) -> list: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self._get_fcst_vars("1"): - all_fields_values['fcst_var'] = list(self._get_fcst_vars("1").keys()) + if self.get_fcst_vars(1): + all_fields_values['fcst_var'] = self.get_fcst_vars(1) all_fields_values['stat_name'] = self.get_config_value('list_stat_1') return utils.create_permutations_mv(all_fields_values, 0) @@ -301,12 +283,11 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: + if isinstance(self.fcst_var_val_1, list): fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: + elif isinstance(self.fcst_var_val_1, dict): fcst_vals = list(self.fcst_var_val_1.values()) - fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] - series_vals_list.append(fcst_vals_flat) + series_vals_list.append(fcst_vals) # 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 diff --git a/metplotpy/plots/bar/bar_series.py b/metplotpy/plots/bar/bar_series.py index aa73bd23..74b70bcd 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_plotly as util +from metplotpy.plots import util from .. import GROUP_SEPARATOR from ..series import Series @@ -54,8 +54,8 @@ def _create_all_fields_values_no_indy(self) -> dict: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self.config._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(1).keys()) + if self.config.get_fcst_vars(1): + all_fields_values['fcst_var'] = self.config.get_fcst_vars(1) all_fields_values['stat_name'] = self.config.get_config_value('list_stat_1') all_fields_values_no_indy[1] = all_fields_values diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 60f698ca..17c34690 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -22,6 +22,7 @@ from typing import Union from metplotpy.plots.util import strtobool from .config import Config +from . import constants turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime @@ -183,7 +184,6 @@ def get_weights_size_styles(self): # 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] @@ -200,6 +200,16 @@ def get_weights_size_styles(self): ylab_property.set_weight(ylab_wt) weights_size_styles['ylab'] = ylab_property + + # For x2axis label if set + if self.config_obj.x2lab_weight: + x2lab_property= FontProperties() + x2lab_property.set_size(self.config_obj.x2_title_font_size) + x2lab_style, x2lab_wt = self.config_obj.x2lab_weight + x2lab_property.set_style(x2lab_style) + x2lab_property.set_weight(x2lab_wt) + weights_size_styles['x2lab'] = x2lab_property + return weights_size_styles @@ -393,3 +403,22 @@ def get_array_dimensions(data): np_array = np.array(data) return len(np_array.shape) + + def _add_title(self, ax, font_properties): + ax.set_title( + self.config_obj.title, + fontproperties=font_properties, + color=constants.DEFAULT_TITLE_COLOR, + pad=28, + x=self.config_obj.parameters['title_align'], + y=self.config_obj.title_offset, + ) + + def _add_caption(self, plt, font_properties): + y_pos = max(0.01, self.config_obj.caption_offset) + plt.figtext( + self.config_obj.caption_align, y_pos, + self.config_obj.plot_caption, + fontproperties=font_properties, + color=self.config_obj.parameters['caption_col'], + ) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index a1dec79f..65571e8d 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -145,8 +145,10 @@ def __init__(self, parameters): # 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.xlab_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('xlab_weight')] + self.x2lab_weight = self.get_config_value('x2lab_weight') + if self.x2lab_weight: + self.x2lab_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.x2lab_weight] self.x_tickangle = self.parameters['xtlab_orient'] if self.x_tickangle in constants.XAXIS_ORIENTATION.keys(): @@ -245,8 +247,8 @@ def __init__(self, parameters): # 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) + 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') @@ -374,7 +376,7 @@ def _get_series_vals(self, index:int) -> list: def _get_series_columns(self, index): ''' Retrieve the column name that corresponds to this ''' - def _get_fcst_vars(self, index: int) -> list: + def get_fcst_vars(self, index: int) -> list: """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 8e822b53..0db6458f 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -24,6 +24,8 @@ MM_TO_INCHES = 0.03937008 CM_TO_INCHES = MM_TO_INCHES * 0.1 +PIXELS_TO_POINTS = 0.72 + # Available Matplotlib Line styles # ':' ... # '-.' _._. @@ -57,9 +59,9 @@ # Default size used in plotly legend text DEFAULT_LEGEND_FONTSIZE = 12 DEFAULT_CAPTION_FONTSIZE = 14 -DEFAULT_CAPTION_Y_OFFSET = -3.1 +DEFAULT_CAPTION_Y_OFFSET = 0.01 DEFAULT_TITLE_FONT_SIZE = 11 -DEFAULT_TITLE_OFFSET = (-0.48) +DEFAULT_TITLE_OFFSET = 0.02 AVAILABLE_MARKERS_LIST = ["o", "^", "s", "d", "H", ".", "h"] diff --git a/test/bar/bar_with_nones.yaml b/test/bar/bar_with_nones.yaml index 1ddb42d2..d6b93ee3 100644 --- a/test/bar/bar_with_nones.yaml +++ b/test/bar/bar_with_nones.yaml @@ -161,4 +161,4 @@ ytlab_perp: 0.5 ytlab_size: 1 show_legend: - -True \ No newline at end of file +- True \ No newline at end of file diff --git a/test/bar/custom_bar.yaml b/test/bar/custom_bar.yaml index eb9df890..c9787f3e 100644 --- a/test/bar/custom_bar.yaml +++ b/test/bar/custom_bar.yaml @@ -1,4 +1,5 @@ alpha: 0.05 +plot_caption: 'test_caption' caption_align: 0.0 caption_col: '#333333' caption_offset: 3.0 @@ -55,7 +56,6 @@ mgp: - 0 num_iterations: 1 num_threads: -1 -plot_caption: '' plot_disp: - 'True' - 'True' @@ -142,5 +142,5 @@ plot_filename: !ENV '${TEST_OUTPUT}/bar.png' # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True - -True \ No newline at end of file +- True +- True \ No newline at end of file diff --git a/test/bar/threshold_bar.yaml b/test/bar/threshold_bar.yaml index 561d7ccd..79e55371 100644 --- a/test/bar/threshold_bar.yaml +++ b/test/bar/threshold_bar.yaml @@ -166,4 +166,4 @@ ytlab_orient: 1 ytlab_perp: 0.5 ytlab_size: 3 show_legend: - -True +- True From 5c71d9d835b72678b7e8a6428b163ac6cbf4b3b8 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:20:20 -0700 Subject: [PATCH 22/53] Revert back to using _get_fcst_vars to avoid breaking things, but added functions get_fcst_vars_keys and get_fcst_vars_dict that are more clear of their return values that can eventually be used to replaced calls to the original function that has different return value depending on how it is overridden. Added y2lab weight --- metplotpy/plots/config.py | 45 +++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 65571e8d..154b6244 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -181,6 +181,9 @@ def __init__(self, parameters): # 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] + self.y2lab_weight = self.get_config_value('y2lab_weight') + if self.y2lab_weight: + self.y2lab_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.y2lab_weight] # Adjust the caption left/right relative to the y-axis # METviewer default is set to 0, corresponds to y=0.05 in Matplotlib @@ -229,11 +232,8 @@ def __init__(self, parameters): 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 + self.draw_box = legend_box == 'o' # These are the inner keys to the series_val setting, and # they represent the series variables of @@ -247,8 +247,8 @@ def __init__(self, parameters): # 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) + 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') @@ -376,7 +376,7 @@ def _get_series_vals(self, index:int) -> list: def _get_series_columns(self, index): ''' Retrieve the column name that corresponds to this ''' - def get_fcst_vars(self, index: int) -> list: + def _get_fcst_vars(self, index: int) -> list: """ Retrieve a list of the inner keys (fcst_vars) to the fcst_var_val dictionary. @@ -406,6 +406,37 @@ def get_fcst_vars(self, index: int) -> list: return all_fcst_vars + def get_fcst_vars_dict(self, index: int) -> dict: + """Retrieve a dictionary of the fcst_var_val_{index} variable from the config. + + 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 not in (1, 2): + return {} + + return self.get_config_value(f'fcst_var_val_{index}') + + def get_fcst_vars_keys(self, index: int) -> list: + """Retrieve a list of keys from the fcst_var_val_{index} variable from the config. + + 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. + + """ + return list(self.get_fcst_vars_dict(index).keys()) + def _get_series_val_names(self) -> list: """ Get a list of all the variable value names (i.e. inner key of the From 9e0979fe2125fb6d0148ee37c4de9560a2a444a0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:20:42 -0700 Subject: [PATCH 23/53] added default widths of some matplotlib shapes --- metplotpy/plots/constants.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/metplotpy/plots/constants.py b/metplotpy/plots/constants.py index 0db6458f..5b91b679 100644 --- a/metplotpy/plots/constants.py +++ b/metplotpy/plots/constants.py @@ -92,3 +92,6 @@ # Matplotlib constants MPL_FONT_SIZE_DEFAULT = 11 + +MPL_DEFAULT_BAR_WIDTH = 0.8 +MPL_DEFAULT_BOX_WIDTH = 0.5 From d1477d64bb74f5f4039e9e7504a489bbf6da0757 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:23:46 -0700 Subject: [PATCH 24/53] added back _get_fcst_vars override to avoid breaking things for now. Change how fcst vals are read to assume it is always a dict because the values set when it is a list do not match -- list gets keys of fcst_var_val_1/2 which is not the same as the list of lists of the stats, e.g. ME --- metplotpy/plots/bar/bar_config.py | 28 ++++++++++++++++++++++------ metplotpy/plots/bar/bar_series.py | 4 ++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index d5b75b39..9ee7bc69 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -138,6 +138,24 @@ def _get_plot_disp(self) -> list: return self.create_list_by_series_ordering(plot_display_bools) + def _get_fcst_vars(self, index): + """ + 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. + """ + + fcst_var_val_dict = self.get_config_value('fcst_var_val_1') + if not fcst_var_val_dict: + fcst_var_val_dict = {} + + return fcst_var_val_dict + def _get_plot_stat(self) -> str: """ Retrieves the plot_stat setting from the config file. @@ -249,8 +267,8 @@ def get_series_y(self) -> list: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self.get_fcst_vars(1): - all_fields_values['fcst_var'] = self.get_fcst_vars(1) + if self.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(1) all_fields_values['stat_name'] = self.get_config_value('list_stat_1') return utils.create_permutations_mv(all_fields_values, 0) @@ -283,10 +301,8 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list): - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict): - fcst_vals = list(self.fcst_var_val_1.values()) + fcst_vals = list(self.fcst_var_val_1.values()) + fcst_vals = [item for sublist in fcst_vals for item in sublist] series_vals_list.append(fcst_vals) # Utilize itertools' product() to create the cartesian product of all elements diff --git a/metplotpy/plots/bar/bar_series.py b/metplotpy/plots/bar/bar_series.py index 74b70bcd..f455bd15 100644 --- a/metplotpy/plots/bar/bar_series.py +++ b/metplotpy/plots/bar/bar_series.py @@ -54,8 +54,8 @@ def _create_all_fields_values_no_indy(self) -> dict: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self.config.get_fcst_vars(1): - all_fields_values['fcst_var'] = self.config.get_fcst_vars(1) + if self.config._get_fcst_vars(1): + all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(1).keys()) all_fields_values['stat_name'] = self.config.get_config_value('list_stat_1') all_fields_values_no_indy[1] = all_fields_values From 8917e017578fe4264f50753af0b409f40db06f93 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:24:39 -0700 Subject: [PATCH 25/53] move more common functionality into the base plot class so it can be used by bar and box plots (and likely others) --- metplotpy/plots/bar/bar.py | 93 +------------------- metplotpy/plots/base_plot.py | 159 +++++++++++++++++++++++++++-------- 2 files changed, 128 insertions(+), 124 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 5a5d9fe4..0e396d7c 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -21,9 +21,8 @@ import pandas as pd from matplotlib import pyplot as plt -from matplotlib.font_manager import FontProperties - import metcalcpy.util.utils as calc_util + from metplotpy.plots import util from metplotpy.plots import constants from metplotpy.plots.bar.bar_config import BarConfig @@ -168,9 +167,6 @@ def _create_figure(self): """ Create a bar plot from defaults and custom parameters """ - self._n_visible_series = sum(1 for s in self.series_list if s.plot_disp) - self._group_width = 0.8 # matplotlib default - # create and draw the plot _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) @@ -240,8 +236,9 @@ def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: x_points = sorted(series.series_data[self.config_obj.indy_var].unique()) base = np.arange(len(x_points)) - n = max(self._n_visible_series, 1) - width = self._group_width / n + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = constants.MPL_DEFAULT_BAR_WIDTH / n offset = (idx - (n - 1) / 2.0) * width x_locs = base + offset @@ -249,84 +246,6 @@ def _draw_series(self, ax: plt.Axes, series: BarSeries, idx: int) -> None: ax.bar(x=x_locs, height=y_points, width=width, align='center', color=self.config_obj.colors_list[series.idx], label=self.config_obj.user_legends[series.idx]) - def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: - """ - Configures and adds x-axis to the plot - """ - ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, - labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) - xtick_locs = np.arange(len(self.config_obj.indy_label)) - ax.set_xticks(xtick_locs, self.config_obj.indy_label) - ax.tick_params(axis="x", direction="in", which="both", labelrotation=self.config_obj.x_tickangle) - if self.config_obj.grid_on: - ax.grid(True, which='major', axis='x', color=self.config_obj.blended_grid_col, - linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) - ax.set_axisbelow(True) - - if self.config_obj.xaxis_reverse is True: - ax.invert_xaxis() - - def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: - """ - Configures and adds y-axis to the plot - """ - ax.set_ylabel(self.config_obj.yaxis_1, fontproperties=fontproperties, - labelpad=abs(self.config_obj.parameters['ylab_offset']) * constants.PIXELS_TO_POINTS) - ax.tick_params(axis="y", direction="in", which="both", labelrotation=self.config_obj.y_tickangle) - - # set y limits if defined - if len(self.config_obj.parameters['ylim']) > 0: - ax.set_ylim(self.config_obj.parameters['ylim']) - - # add grid lines if requested - if self.config_obj.grid_on: - ax.grid(True, which='major', axis='y', color=self.config_obj.blended_grid_col, linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) - ax.set_axisbelow(True) - - def _add_legend(self, ax: plt.Axes) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" - - handles, labels = ax.get_legend_handles_labels() - if not handles: - print("Warning: No labels found. Use ax.plot(..., label='name')") - - legend = ax.legend( - handles=handles, - labels=labels, - bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), - loc='upper center', - edgecolor=self.config_obj.legend_border_color, - frameon=True, - ncol=max(1, len(handles)) if orientation == "horizontal" else 1, - fontsize=self.config_obj.legend_size, - labelcolor="black" - ) - if legend: - frame = legend.get_frame() - frame.set_linewidth(self.config_obj.legend_border_width) - - - def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure - - :param n_stats: - labels for the axis - """ - if self.config_obj.show_nstats: - ax_top = ax.secondary_xaxis('top') - ax_top.set_xlabel('NStats', fontproperties=fontproperties, - labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) - current_locs = ax.get_xticks() - ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) - # this doesn't appear to be working to add ticks at the top - ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) - - def write_output_file(self) -> None: """ Formats series point data to the 2-dim arrays and saves them to the files @@ -359,10 +278,6 @@ def write_output_file(self) -> None: for series in self.series_list: f.write(f"{series.series_points['dbl_med']}\n") - def save_to_file(self) -> None: - image_name = self.get_config_value('plot_filename') - os.makedirs(os.path.dirname(image_name), exist_ok=True) - plt.savefig(image_name, dpi=self.get_config_value('plot_res')) def main(config_filename=None): """ diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 17c34690..0b63b78a 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -18,6 +18,7 @@ import warnings import numpy as np from matplotlib.font_manager import FontProperties +from matplotlib import pyplot as plt import yaml from typing import Union from metplotpy.plots.util import strtobool @@ -200,9 +201,8 @@ def get_weights_size_styles(self): ylab_property.set_weight(ylab_wt) weights_size_styles['ylab'] = ylab_property - # For x2axis label if set - if self.config_obj.x2lab_weight: + if hasattr(self.config_obj, 'x2lab_weight') and hasattr(self.config_obj, 'x2_title_font_size'): x2lab_property= FontProperties() x2lab_property.set_size(self.config_obj.x2_title_font_size) x2lab_style, x2lab_wt = self.config_obj.x2lab_weight @@ -210,6 +210,16 @@ def get_weights_size_styles(self): x2lab_property.set_weight(x2lab_wt) weights_size_styles['x2lab'] = x2lab_property + + # For y2axis label if set + if hasattr(self.config_obj, 'y2lab_weight') and hasattr(self.config_obj, 'y2_title_font_size'): + y2lab_property= FontProperties() + y2lab_property.set_size(self.config_obj.y2_title_font_size) + y2lab_style, y2lab_wt = self.config_obj.y2lab_weight + y2lab_property.set_style(y2lab_style) + y2lab_property.set_weight(y2lab_wt) + weights_size_styles['y2lab'] = y2lab_property + return weights_size_styles @@ -270,39 +280,13 @@ def get_img_bytes(self): 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: - - """ + def save_to_file(self) -> None: 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.") + os.makedirs(os.path.dirname(image_name), exist_ok=True) + try: + plt.savefig(image_name, dpi=self.get_config_value('plot_res')) + except Exception as ex: + self.logger.error(f"Failed to save plot to file: {ex}") def remove_file(self): """Removes previously made image file . @@ -369,7 +353,8 @@ def _add_lines(self, config_obj: Config, x_points_index: Union[list, None] = Non # draw lines self.figure.update_layout(shapes=shapes) - def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + @staticmethod + def add_horizontal_line(plt, y: float, line_properties: dict) -> None: """Adds a horizontal line to the matplotlib plot @param plt: Matplotlib pyplot object @@ -379,6 +364,7 @@ def add_horizontal_line(plt,y: float, line_properties: dict) -> None: """ plt.axhline(y=y, xmin=0, xmax=1, **line_properties) + @staticmethod def add_vertical_line(plt, x: float, line_properties: dict) -> None: """Adds a vertical line to the matplotlib plot @@ -422,3 +408,106 @@ def _add_caption(self, plt, font_properties): fontproperties=font_properties, color=self.config_obj.parameters['caption_col'], ) + + def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: + """ + Creates a plot legend based on the properties from the config file + and attaches it to the initial Figure + """ + orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" + + handles, labels = ax.get_legend_handles_labels() + if handles_and_labels: + handles = [item[0] for item in handles_and_labels] + labels = [item[1] for item in handles_and_labels] + + if not handles: + print("Warning: No labels found. Use ax.plot(..., label='name')") + + # only show legend entries that have show_legend set to True + filtered_handles = [h for h, show in zip(handles, self.config_obj.show_legend) if show == 1] + filtered_labels = [l for l, show in zip(labels, self.config_obj.show_legend) if show == 1] + + legend = ax.legend( + handles=filtered_handles, + labels=filtered_labels, + bbox_to_anchor=(self.config_obj.bbox_x, self.config_obj.bbox_y), + loc='upper center', + edgecolor=self.config_obj.legend_border_color, + frameon=True, + ncol=max(1, len(handles)) if orientation == "horizontal" else 1, + fontsize=self.config_obj.legend_size, + labelcolor="black" + ) + if legend: + frame = legend.get_frame() + frame.set_linewidth(self.config_obj.legend_border_width) + + def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: + """ + Configures and adds x-axis to the plot + """ + ax.set_xlabel(self.config_obj.xaxis, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['xlab_offset']) * constants.PIXELS_TO_POINTS) + xtick_locs = np.arange(len(self.config_obj.indy_label)) + ax.set_xticks(xtick_locs, self.config_obj.indy_label) + ax.tick_params(axis="x", direction="in", which="both", labelrotation=self.config_obj.x_tickangle) + if self.config_obj.grid_on: + ax.grid(True, which='major', axis='x', color=self.config_obj.blended_grid_col, + linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) + ax.set_axisbelow(True) + + if self.config_obj.xaxis_reverse is True: + ax.invert_xaxis() + + def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: + """ + Configures and adds y-axis to the plot + """ + ax.set_ylabel(self.config_obj.yaxis_1, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['ylab_offset']) * constants.PIXELS_TO_POINTS) + ax.tick_params(axis="y", direction="in", which="both", labelrotation=self.config_obj.y_tickangle) + + # set y limits if defined in config or if min/max are provided + if len(self.config_obj.parameters['ylim']) > 0: + ax.set_ylim(self.config_obj.parameters['ylim']) + + # add grid lines if requested + if self.config_obj.grid_on: + ax.grid(True, which='major', axis='y', color=self.config_obj.blended_grid_col, linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) + ax.set_axisbelow(True) + + def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: + """ + Creates x2axis based on the properties from the config file + and attaches it to the initial Figure + + :param n_stats: - labels for the axis + """ + if not self.config_obj.show_nstats: + return + + ax_top = ax.secondary_xaxis('top') + ax_top.set_xlabel('NStats', fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) + current_locs = ax.get_xticks() + ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) + # this doesn't appear to be working to add ticks at the top + ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) + + def _add_y2axis(self, ax: plt.Axes, fontproperties: FontProperties): + """ + Adds y2-axis if needed + """ + if not self.config_obj.parameters['list_stat_2']: + return None + + ax_right = ax.twinx() + ax_right.set_ylabel(self.config_obj.yaxis_2, fontproperties=fontproperties, + labelpad=abs(self.config_obj.parameters['y2lab_offset']) * constants.PIXELS_TO_POINTS) + + # set y2 limits if defined in config + if len(self.config_obj.parameters['y2lim']) > 0: + ax_right.set_ylim(self.config_obj.parameters['y2lim']) + + return ax_right From 021f9232ec7e3e0d5bca2f206f40995900d62d85 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:27:54 -0700 Subject: [PATCH 26/53] Progress towards creating box plots using matplotlib. Adjustment is still needed to render the plots correctly --- metplotpy/plots/base_plot.py | 7 +- metplotpy/plots/box/box.py | 484 ++++++++---------------------- metplotpy/plots/box/box_config.py | 12 +- metplotpy/plots/box/box_series.py | 5 +- 4 files changed, 137 insertions(+), 371 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 0b63b78a..be9ce369 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -479,8 +479,11 @@ def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure + Creates x2axis based on the properties from the config file. + + Note: this function may need to be called after adding the series, because some + plots add ticks that will conflict with the explicit x ticks set in this function. + Calliing this after will override the ticks and prevent a conflict. :param n_stats: - labels for the axis """ diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 4747f141..1c3b898d 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -21,19 +21,17 @@ from operator import add from itertools import chain import pandas as pd +import numpy as np -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt import metcalcpy.util.utils as calc_util -from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.box.box_config import BoxConfig from metplotpy.plots.box.box_series import BoxSeries -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 - +from metplotpy.plots import util +from metplotpy.plots import constants class Box(BasePlot): """ Generates a Plotly box plot for 1 or more traces @@ -128,8 +126,7 @@ def _create_series(self, input_data): """ - self.logger.info(f"Begin generating series objects: " - f"{datetime.now()}") + self.logger.info(f"Begin generating series objects: {datetime.now()}") series_list = [] # add series for y1 axis @@ -172,76 +169,93 @@ def _create_series(self, input_data): # reorder series series_list = self.config_obj.create_list_by_series_ordering(series_list) - self.logger.info(f"End generating series objects: " - f"{datetime.now()}") + if self.config_obj.xaxis_reverse: + series_list.reverse() + + self.logger.info(f"End generating series objects: {datetime.now()}") return series_list def _create_figure(self): """ Create a box plot from default and custom parameters""" - self.logger.info(f"Begin creating the figure: " - f"{datetime.now()}") - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_y2axis() - self._add_legend() - - # placeholder for the number of stats - n_stats = [0] * len(self.config_obj.indy_vals) + self.logger.info(f"Begin creating the figure: {datetime.now()}") - # placeholder for the min and max values for y-axis - yaxis_min = None - yaxis_max = None + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) - if self.config_obj.xaxis_reverse is True: - self.series_list.reverse() + wts_size_styles = self.get_weights_size_styles() - for series in self.series_list: - # Don't generate the plot for this series if - # it isn't requested (as set in the config file) - if series.plot_disp: - # collect min-max if we need to sync axis - if self.config_obj.sync_yaxes is True: - yaxis_min, yaxis_max = self._find_min_max(series, yaxis_min, yaxis_max) + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) - self._draw_series(series) + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) - # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + n_stats, handles_and_labels, yaxis_min, yaxis_max = self._add_series(ax, ax_y2) + + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) # add custom lines - if len(self.series_list) > 0: - self._add_lines( - self.config_obj, - sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) - ) + # if len(self.series_list) > 0: + # self._add_lines( + # self.config_obj, + # sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) + # ) - # apply y axis limits - self._yaxis_limits() - self._y2axis_limits() + # add x2 axis + self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) - # sync axis - self._sync_yaxis(yaxis_min, yaxis_max) + self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) + self._add_legend(ax, handles_and_labels) - # add x2 axis - self._add_x2axis(n_stats) - self.figure.update_layout(boxmode='group') + #plt.tight_layout() + #self.figure.update_layout(boxmode='group') - self.logger.info(f"End creating the figure: " - f"{datetime.now()}") + self.logger.info(f"End creating the figure: {datetime.now()}") - def _draw_series(self, series: BoxSeries) -> None: + def _sync_yaxes(self, ax, ax2, yaxis_min: Union[float, None], yaxis_max: Union[float, None]): + if not self.config_obj.sync_yaxes: + return + + # set y limits if defined in config or if min/max are provided + if len(self.config_obj.parameters['ylim']) > 0: + yaxis_min = self.config_obj.parameters['ylim'][0] + yaxis_max = self.config_obj.parameters['ylim'][1] + + if yaxis_min is not None and yaxis_max is not None: + ax.set_ylim(yaxis_min, yaxis_max) + ax2.set_ylim(yaxis_min, yaxis_max) + + def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): """ Draws the boxes on the plot :param series: Line series object with data and parameters """ - self.logger.info(f"Begin drawing the boxes on the plot for " - f"{series.series_name}: " - f"{datetime.now()}") + self.logger.info(f"Begin drawing the boxes on the plot for {series.series_name}: {datetime.now()}") + + base = np.arange(len(self.config_obj.indy_vals)) + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = constants.MPL_DEFAULT_BOX_WIDTH / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset + + # Group your 'stat_value' data by 'indy_var' categories first + data_to_plot = [group_data for name, group_data in + series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + + plot_ax = ax + if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: + plot_ax = ax2 + + boxplot = plot_ax.boxplot(data_to_plot, positions=x_locs, patch_artist=True, widths=width, + label=self.config_obj.user_legends[series.idx]) + for box in boxplot['boxes']: + box.set_facecolor(series.color) + + return boxplot['boxes'][0] # defaults markers and colors for the regular box plot line_color = dict(color='rgb(0,0,0)') fillcolor = series.color @@ -283,11 +297,31 @@ def _draw_series(self, series: BoxSeries) -> None: secondary_y=series.y_axis != 1 ) - self.logger.info(f"End drawing the boxes on the plot: " - f"{datetime.now()}") + self.logger.info(f"End drawing the boxes on the plot: {datetime.now()}") + + def _add_series(self, ax, ax2): + handles_and_labels = [] + n_stats = [0] * len(self.config_obj.indy_vals) + yaxis_min = None + yaxis_max = None + + for idx, series in enumerate(self.series_list): + # Don't generate the plot for this series if + # it isn't requested (as set in the config file) + if series.plot_disp: + # collect min-max if we need to sync axis + if self.config_obj.sync_yaxes: + yaxis_min, yaxis_max = self._find_min_max(series, yaxis_min, yaxis_max) + + handle = self._draw_series(ax, ax2, series, idx) + handles_and_labels.append((handle, handle.get_label())) - @staticmethod - def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], + # aggregate number of stats + n_stats = list(map(add, n_stats, series.series_points['nstat'])) + + return n_stats, handles_and_labels, yaxis_min, yaxis_max + + def _find_min_max(self, series: BoxSeries, yaxis_min: Union[float, None], yaxis_max: Union[float, None]) -> tuple: """ Finds min and max value between provided min and max and y-axis CI values of this series @@ -298,8 +332,7 @@ def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], :param yaxis_max: previously calculated max value :return: a tuple with calculated min/max """ - self.logger.info(f"Begin finding min and max CI values: " - f"{datetime.now()}") + self.logger.info(f"Begin finding min and max CI values: {datetime.now()}") # calculate series upper and lower limits of CIs indexes = range(len(series.series_points['dbl_med'])) upper_range = [series.series_points['dbl_med'][i] + series.series_points['dbl_up_ci'][i] @@ -310,274 +343,10 @@ def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], if yaxis_min is None or yaxis_max is None: return min(low_range), max(upper_range) - self.logger.info(f"End finding min and max CI values: " - f"{datetime.now()}") + self.logger.info(f"End finding min and max CI values: {datetime.now()}") return min(chain([yaxis_min], low_range)), max(chain([yaxis_max], upper_range)) - def _yaxis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['ylim']) > 0: - self.figure.update_layout(yaxis={'range': [self.config_obj.parameters['ylim'][0], - self.config_obj.parameters['ylim'][1]], - 'autorange': False}) - - def _y2axis_limits(self) -> None: - """ - Apply limits on y2 axis if needed - """ - if len(self.config_obj.parameters['y2lim']) > 0: - self.figure.update_layout(yaxis2={'range': [self.config_obj.parameters['y2lim'][0], - self.config_obj.parameters['y2lim'][1]], - 'autorange': False}) - - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters['caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters['title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout and allow y2 axis - fig = make_subplots(specs=[[{"secondary_y": True}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - - fig.update_layout( - xaxis={ - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': self.config_obj.indy_label - } - ) - - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - ticks="inside", - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size} - ) - # reverse xaxis if needed - if hasattr( self.config_obj, 'xaxis_reverse' ) and self.config_obj.xaxis_reverse is True: - self.figure.update_xaxes(autorange="reversed") - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters['ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size}, - exponentformat='none' - ) - - def _add_y2axis(self) -> None: - """ - Adds y2-axis if needed - """ - if self.config_obj.parameters['list_stat_2']: - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_2, - self.config_obj.parameters['y2lab_weight']), - secondary_y=True, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - ticks="inside", - title_font={ - 'size': self.config_obj.y2_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['y2lab_offset']), - tickangle=self.config_obj.y2_tickangle, - tickfont={'size': self.config_obj.y2_tickfont_size}, - exponentformat='none' - ) - - def _sync_yaxis(self, yaxis_min: Union[float, None], yaxis_max: Union[float, None]) -> None: - """ - Forces y1 and y2 axes sync if needed by specifying the same limits on both axis. - Use ylim property to determine the limits. If this value is not provided - - use method parameters - - :param yaxis_min: min value or None - :param yaxis_max: max value or None - """ - if self.config_obj.sync_yaxes is True: - if len(self.config_obj.parameters['ylim']) > 0: - # use plot config parameter - range_min = self.config_obj.parameters['ylim'][0] - range_max = self.config_obj.parameters['ylim'][1] - else: - # use method parameter - range_min = yaxis_min - range_max = yaxis_max - - if range_min is not None and range_max is not None: - # update y axis - self.figure.update_layout(yaxis={'range': [range_min, - range_max], - 'autorange': False}) - - # update y2 axis - self.figure.update_layout(yaxis2={'range': [range_min, - range_max], - 'autorange': False}) - - def _add_x2axis(self, n_stats) -> None: - """ - Creates x2axis based on the properties from the config file - and attaches it to the initial Figure - - :param n_stats: - labels for the axis - """ - if self.config_obj.show_nstats: - self.figure.update_layout(xaxis2={'title_text': - util.apply_weight_style('NStats', - self.config_obj.parameters['x2lab_weight'] - ), - 'linecolor': PLOTLY_AXIS_LINE_COLOR, - 'linewidth': PLOTLY_AXIS_LINE_WIDTH, - 'overlaying': 'x', - 'side': 'top', - 'showgrid': False, - 'zeroline': False, - 'ticks': "inside", - 'title_font': { - 'size': self.config_obj.x2_title_font_size - }, - 'title_standoff': abs( - self.config_obj.parameters['x2lab_offset'] - ), - 'tickmode': 'array', - 'tickvals': self.config_obj.indy_vals, - 'ticktext': n_stats, - 'tickangle': self.config_obj.x2_tickangle, - 'tickfont': { - 'size': self.config_obj.x2_tickfont_size - }, - 'scaleanchor': 'x' - } - ) - # reverse x2axis if needed - if self.config_obj.xaxis_reverse is True: - self.figure.update_layout(xaxis2={'autorange':"reversed"}) - - # need to add an invisible line with all values = None - self.figure.add_trace( - go.Scatter(y=[None] * len(self.config_obj.indy_vals), x=self.config_obj.indy_vals, - xaxis='x2', showlegend=False) - ) - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': self.config_obj.legend_border_color, - 'borderwidth': self.config_obj.legend_border_width, - 'orientation': self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - }, - 'traceorder': 'normal' - }) - if hasattr( self.config_obj, 'xaxis_reverse' ) and self.config_obj.xaxis_reverse is True: - self.figure.update_layout(legend={'traceorder':'reversed'}) - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT Plotly.js - """ - self.config_obj.logger.info(f"Begin writing HTML file: " - f"{datetime.now()}") - - # is_create = self.config_obj.create_html - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) - - self.logger.info(f"End writing HTML file: " - f"{datetime.now()}") - def write_output_file(self) -> None: """ Formats y1 and y2 series point data to the 2-dim arrays and saves them to the files @@ -589,44 +358,39 @@ def write_output_file(self) -> None: # otherwise use points_path path match = re.match(r'(.*)(.data)', self.config_obj.parameters['stat_input']) - if self.config_obj.dump_points_1 is True or self.config_obj.dump_points_2 is True and match: - filename = match.group(1) - # replace the default path with the custom - if self.config_obj.points_path is not None: - # get the file name - path = filename.split(os.path.sep) - if len(path) > 0: - filename = path[-1] + if not self.config_obj.dump_points_1 and not self.config_obj.dump_points_2 or not match: + return + + filename = match.group(1) + # replace the default path with the custom + if self.config_obj.points_path is not None: + filename = os.path.join(self.config_obj.points_path, os.path.basename(filename)) + + filename = f"{filename}.points1" + if os.path.exists(filename): + os.remove(filename) + # create directory if needed + os.makedirs(os.path.dirname(filename), exist_ok=True) + + for series in self.series_list: + for indy_val in self.config_obj.indy_vals: + if calc_util.is_string_integer(indy_val): + data_for_indy = series.series_data[ + series.series_data[self.config_obj.indy_var] == int(indy_val)] + elif calc_util.is_string_strictly_float(indy_val): + data_for_indy = series.series_data[ + series.series_data[self.config_obj.indy_var] == float(indy_val)] else: - filename = '.' + os.path.sep - filename = self.config_obj.points_path + os.path.sep + filename - - filename = filename + '.points1' - if os.path.exists(filename): - os.remove(filename) - # create directory if needed - os.makedirs(os.path.dirname(filename), exist_ok=True) - - for series in self.series_list: - for indy_val in self.config_obj.indy_vals: - if calc_util.is_string_integer(indy_val): - data_for_indy = series.series_data[ - series.series_data[self.config_obj.indy_var] == int(indy_val)] - elif calc_util.is_string_strictly_float(indy_val): - data_for_indy = series.series_data[ - series.series_data[self.config_obj.indy_var] == float(indy_val)] - else: - data_for_indy = series.series_data[ - series.series_data[self.config_obj.indy_var] == indy_val] - - file_object = open(filename, 'a') + data_for_indy = series.series_data[ + series.series_data[self.config_obj.indy_var] == indy_val] + + with open(filename, 'a') as file_object: file_object.write('\n') file_object.write(' '.join([str(elem) for elem in series.series_name]) + ' ' + indy_val) file_object.write('\n') - file_object.close() - quantile_data = data_for_indy['stat_value'].quantile([0, 0.25, 0.5, 0.75, 1]).iloc[::-1] - quantile_data.to_csv(filename, header=False, index=None, sep=' ', mode='a') - file_object.close() + + quantile_data = data_for_indy['stat_value'].quantile([0, 0.25, 0.5, 0.75, 1]).iloc[::-1] + quantile_data.to_csv(filename, header=False, index=None, sep=' ', mode='a') def main(config_filename=None): diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index a576ef65..0f1212c3 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -17,9 +17,9 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants as constants +from .. import util as util import metcalcpy.util.utils as utils @@ -57,12 +57,13 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET + self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET + self.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -87,7 +88,6 @@ def __init__(self, parameters: dict) -> None: 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']) ############################################## # x2-axis parameters diff --git a/metplotpy/plots/box/box_series.py b/metplotpy/plots/box/box_series.py index a0434f3a..a7ca5060 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_plotly as util +import metplotpy.plots.util as util from ..series import Series @@ -265,5 +265,4 @@ def _calculate_derived_values(self, else: self.series_data = pd.concat([self.series_data, (stats_indy_1)], sort=False) - logger.info(f"End calculating derived values: " - f"{datetime.now()}") + logger.info(f"End calculating derived values: {datetime.now()}") From a4fcdd95257300895161b0ed7df419e20a77f464 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 09:28:44 -0700 Subject: [PATCH 27/53] fix incorrectly formatted show_legend values that are interpreted as a single string instead of a list, adjust caption offset --- test/box/custom_box.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index a3749b9b..76bb0488 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -6,7 +6,7 @@ box_outline: 'True' box_pts: 'False' caption_align: 0.0 caption_col: '#333333' -caption_offset: 3.0 +caption_offset: 8.0 caption_size: 0.8 caption_weight: 1 cex: 1 @@ -186,7 +186,7 @@ plot_filename: !ENV '${TEST_OUTPUT}/box.png' # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True - -True - -True - -True \ No newline at end of file +- True +- True +- True +- True \ No newline at end of file From 5f816218b7c86775096b16d31fc13debe6d57481 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:10:54 -0700 Subject: [PATCH 28/53] handle multiple test_*.py files in a test directory by checking the name of the test file and creating a subdirectory if it does not match the test directory, e.g. bar/test_bar.py would write to test_output/bar but bar/test_other.py would write to test_output/bar/other. Previously multiple test files would wipe out the other tests' output. Also switched to using request.node.path because request.fspath is deprecated --- test/conftest.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 0a718653..2316788b 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): From 77b09e26382f5352ed6ede8370dfd2c5939c9e99 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:37:10 -0700 Subject: [PATCH 29/53] move caption_weight handling to base config --- metplotpy/plots/bar/bar_config.py | 2 -- metplotpy/plots/box/box_config.py | 1 - metplotpy/plots/config.py | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 9ee7bc69..326cccae 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -37,8 +37,6 @@ def __init__(self, parameters: dict) -> None: """ super().__init__(parameters) - self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] - # Optional setting, indicates *where* to save the dump_points_1 file # used by METviewer self.points_path = self.get_config_value('points_path') diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index 0f1212c3..bdc9eea3 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -58,7 +58,6 @@ def __init__(self, parameters: dict) -> None: self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET - self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[self.get_config_value('caption_weight')] ############################################## # title parameters diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 154b6244..e6e07696 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -59,7 +59,7 @@ def __init__(self, parameters): 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') + self.caption_weight = constants.MV_TO_MPL_CAPTION_STYLE[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') From 3faba155a519cc1e97a582a3b0d4bab696f64c03 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:38:21 -0700 Subject: [PATCH 30/53] progress towards getting revision_box working utilizing as much from box as possible --- metplotpy/plots/box/box.py | 42 ++-- metplotpy/plots/revision_box/revision_box.py | 235 +++++++++--------- .../plots/revision_box/revision_box_config.py | 9 +- .../plots/revision_box/revision_box_series.py | 3 +- 4 files changed, 155 insertions(+), 134 deletions(-) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 1c3b898d..1f920e4a 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -31,7 +31,7 @@ from metplotpy.plots.box.box_config import BoxConfig from metplotpy.plots.box.box_series import BoxSeries from metplotpy.plots import util -from metplotpy.plots import constants +from metplotpy.plots.constants import MPL_DEFAULT_BOX_WIDTH class Box(BasePlot): """ Generates a Plotly box plot for 1 or more traces @@ -187,7 +187,9 @@ def _create_figure(self): self._add_title(ax, wts_size_styles['title']) self._add_caption(plt, wts_size_styles['caption']) - ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) + ax_y2 = None + if wts_size_styles.get('y2lab'): + ax_y2 = self._add_y2axis(ax, wts_size_styles['y2lab']) n_stats, handles_and_labels, yaxis_min, yaxis_max = self._add_series(ax, ax_y2) @@ -202,14 +204,13 @@ def _create_figure(self): # ) # add x2 axis - self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) + if wts_size_styles.get('x2lab'): + self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self._add_legend(ax, handles_and_labels) - - #plt.tight_layout() - #self.figure.update_layout(boxmode='group') + plt.tight_layout() self.logger.info(f"End creating the figure: {datetime.now()}") @@ -235,16 +236,11 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): self.logger.info(f"Begin drawing the boxes on the plot for {series.series_name}: {datetime.now()}") - base = np.arange(len(self.config_obj.indy_vals)) - n_visible_series = sum(1 for s in self.series_list if s.plot_disp) - n = max(n_visible_series, 1) - width = constants.MPL_DEFAULT_BOX_WIDTH / n - offset = (idx - (n - 1) / 2.0) * width - x_locs = base + offset - # Group your 'stat_value' data by 'indy_var' categories first - data_to_plot = [group_data for name, group_data in - series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + data_to_plot, x_locs, width = self._get_data_to_plot_and_x_locs(series, idx) + + # data_to_plot = [group_data for name, group_data in + # series.series_data.groupby(self.config_obj.indy_var)['stat_value']] plot_ax = ax if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: @@ -299,6 +295,18 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): self.logger.info(f"End drawing the boxes on the plot: {datetime.now()}") + def _get_data_to_plot_and_x_locs(self, series, idx): + base = np.arange(len(self.config_obj.indy_vals)) + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = MPL_DEFAULT_BOX_WIDTH / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset + + data_to_plot = [group_data for name, group_data in + series.series_data.groupby(self.config_obj.indy_var)['stat_value']] + return data_to_plot, x_locs, width + def _add_series(self, ax, ax2): handles_and_labels = [] n_stats = [0] * len(self.config_obj.indy_vals) @@ -317,7 +325,9 @@ def _add_series(self, ax, ax2): handles_and_labels.append((handle, handle.get_label())) # aggregate number of stats - n_stats = list(map(add, n_stats, series.series_points['nstat'])) + # do not increment n_stats if it is not set, e.g. for revision_box + if series.series_points.get('nstat'): + n_stats = list(map(add, n_stats, series.series_points['nstat'])) return n_stats, handles_and_labels, yaxis_min, yaxis_max diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 35aa020b..30d97583 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -9,21 +9,20 @@ """ Class Name: revision_box.py - """ +""" import os import re from datetime import datetime -import plotly.graph_objects as go -from metplotpy.plots.base_plot_plotly import BasePlot +from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.box.box import Box -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util import metcalcpy.util.utils as calc_util -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 +from metplotpy.plots.constants import MPL_DEFAULT_BOX_WIDTH class RevisionBox(Box): @@ -53,7 +52,7 @@ def __init__(self, parameters: dict) -> None: # Check that we have all the necessary settings for each series is_config_consistent = self.config_obj._config_consistency_check() if not is_config_consistent: - raise ValueError("The number of series defined by series_val_1 is" + raise ValueError("The number of series defined by series_val_1 is" " inconsistent with the number of settings" " required for describing each series. Please check" " the number of your configuration file's plot_i," @@ -121,126 +120,136 @@ def _create_figure(self): """ Create a box plot from default and custom parameters""" self.logger.info(f"Begin creating the figure: {datetime.now()}") - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + + #self.figure = self._create_layout() + #self._add_xaxis() + #self._add_yaxis() + #self._add_legend() annotation_text_all = '' for inx, series in enumerate(self.series_list): # Don't generate the plot for this series if # it isn't requested (as set in the config file) - if series.plot_disp: - self._draw_series(series) - # construct annotation text - annotation_text = series.user_legends + ': ' - if self.config_obj.revision_run: - annotation_text = annotation_text + 'WW Runs Test:' + series.series_points['revision_run'] + ' ' + if not series.plot_disp: + continue - if self.config_obj.revision_ac: - annotation_text = annotation_text + "Auto-Corr Test: p=" \ - + series.series_points['auto_cor_p'] \ - + ", r=" + series.series_points['auto_cor_r'] + #self._draw_series(series) + # construct annotation text + annotation_text = series.user_legends + ': ' + if self.config_obj.revision_run: + annotation_text = annotation_text + 'WW Runs Test:' + series.series_points['revision_run'] + ' ' + + if self.config_obj.revision_ac: + annotation_text = annotation_text + "Auto-Corr Test: p=" \ + + series.series_points['auto_cor_p'] \ + + ", r=" + series.series_points['auto_cor_r'] - annotation_text_all = annotation_text_all + annotation_text - if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '
' + annotation_text_all = annotation_text_all + annotation_text + if inx < len(self.series_list) - 1: + annotation_text_all = annotation_text_all + '
' + + self.config_obj.plot_caption = annotation_text_all + + super()._create_figure() # add custom lines - if len(self.series_list) > 0: - self._add_lines( - self.config_obj, - sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) - ) + # if len(self.series_list) > 0: + # self._add_lines( + # self.config_obj, + # sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) + # ) # apply y axis limits - self._yaxis_limits() + #self._yaxis_limits() # add Auto-Corr Test and/or WW Runs Test results if needed - if self.config_obj.revision_run or self.config_obj.revision_ac: - self.figure.add_annotation(text=annotation_text_all, - align='left', - showarrow=False, - xref='paper', - yref='paper', - x=0, - yanchor='bottom', - xanchor='left', - y=1, - font={ - 'size': self.config_obj.legend_size, - 'color': "black" - }, - bordercolor=self.config_obj.legend_border_color, - borderwidth=0 - ) - - self.logger.info(f"Finished creating figure: {datetime.now()}") - - def _draw_series(self, series: RevisionBoxSeries) -> None: - """ - Draws the boxes on the plot - - :param series: RevisionBoxSeries object with data and parameters - """ - - self.logger.info(f"Begin drawing series: {datetime.now()}") - # defaults markers and colors for the regular box plot - line_color = dict(color='rgb(0,0,0)') - fillcolor = series.color - marker_color = 'rgb(0,0,0)' - marker_line_color = 'rgb(0,0,0)' - marker_symbol = 'circle-open' - - # markers and colors for points only plot - if self.config_obj.box_pts: - line_color = dict(color='rgba(0,0,0,0)') - fillcolor = 'rgba(0,0,0,0)' - marker_color = series.color - marker_symbol = 'circle' - marker_line_color = series.color - - # create a trace - self.figure.add_trace( - go.Box( # x=[series.idx], - y=series.series_points['points']['stat_value'].tolist(), - notched=self.config_obj.box_notch, - line=line_color, - fillcolor=fillcolor, - name=series.user_legends, - showlegend=self.config_obj.show_legend[series.idx] == 1, - boxmean=self.config_obj.box_avg, - boxpoints=self.config_obj.boxpoints, # outliers, all, False - pointpos=0, - marker=dict(size=4, - color=marker_color, - line=dict( - width=1, - color=marker_line_color - ), - symbol=marker_symbol, - ), - jitter=0 - ) - ) - - self.logger.info(f"Finished drawing series:{datetime.now()}") - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs(self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - tickmode='linear' - ) + # if self.config_obj.revision_run or self.config_obj.revision_ac: + # self.figure.add_annotation(text=annotation_text_all, + # align='left', + # showarrow=False, + # xref='paper', + # yref='paper', + # x=0, + # yanchor='bottom', + # xanchor='left', + # y=1, + # font={ + # 'size': self.config_obj.legend_size, + # 'color': "black" + # }, + # bordercolor=self.config_obj.legend_border_color, + # borderwidth=0 + # ) + + self.logger.info(f"Finished creating figure: {datetime.now()}") + + def _get_data_to_plot_and_x_locs(self, series, idx): + return [series.series_points['points']['stat_value'].tolist()], None, MPL_DEFAULT_BOX_WIDTH + + # def _draw_series(self, series: RevisionBoxSeries) -> None: + # """ + # Draws the boxes on the plot + # + # :param series: RevisionBoxSeries object with data and parameters + # """ + # + # self.logger.info(f"Begin drawing series: {datetime.now()}") + # # defaults markers and colors for the regular box plot + # line_color = dict(color='rgb(0,0,0)') + # fillcolor = series.color + # marker_color = 'rgb(0,0,0)' + # marker_line_color = 'rgb(0,0,0)' + # marker_symbol = 'circle-open' + # + # # markers and colors for points only plot + # if self.config_obj.box_pts: + # line_color = dict(color='rgba(0,0,0,0)') + # fillcolor = 'rgba(0,0,0,0)' + # marker_color = series.color + # marker_symbol = 'circle' + # marker_line_color = series.color + # + # # create a trace + # self.figure.add_trace( + # go.Box( # x=[series.idx], + # y=series.series_points['points']['stat_value'].tolist(), + # notched=self.config_obj.box_notch, + # line=line_color, + # fillcolor=fillcolor, + # name=series.user_legends, + # showlegend=self.config_obj.show_legend[series.idx] == 1, + # boxmean=self.config_obj.box_avg, + # boxpoints=self.config_obj.boxpoints, # outliers, all, False + # pointpos=0, + # marker=dict(size=4, + # color=marker_color, + # line=dict( + # width=1, + # color=marker_line_color + # ), + # symbol=marker_symbol, + # ), + # jitter=0 + # ) + # ) + # + # self.logger.info(f"Finished drawing series:{datetime.now()}") + # + # def _add_xaxis(self) -> None: + # """ + # Configures and adds x-axis to the plot + # """ + # self.figure.update_xaxes(title_text=self.config_obj.xaxis, + # linecolor=PLOTLY_AXIS_LINE_COLOR, + # linewidth=PLOTLY_AXIS_LINE_WIDTH, + # title_font={ + # 'size': self.config_obj.x_title_font_size + # }, + # title_standoff=abs(self.config_obj.parameters['xlab_offset']), + # tickangle=self.config_obj.x_tickangle, + # tickfont={'size': self.config_obj.x_tickfont_size}, + # tickmode='linear' + # ) def write_output_file(self) -> None: """ diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index 873100a1..a9af9eb0 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_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants as constants +from .. import util import metcalcpy.util.utils as utils @@ -40,6 +40,8 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.dump_points_1 = self._get_bool('dump_points_1') self.create_html = self._get_bool('create_html') + self.sync_yaxes = False + self.xaxis_reverse = False ############################################## # caption parameters @@ -67,7 +69,6 @@ def __init__(self, parameters: dict) -> None: 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']) ############################################## # series parameters diff --git a/metplotpy/plots/revision_box/revision_box_series.py b/metplotpy/plots/revision_box/revision_box_series.py index 4c90ffe3..bbd8f1e6 100644 --- a/metplotpy/plots/revision_box/revision_box_series.py +++ b/metplotpy/plots/revision_box/revision_box_series.py @@ -164,7 +164,8 @@ def _create_series_points(self) -> dict: 'revision_run': None, 'auto_cor_r': None, 'auto_cor_p': None, - 'points': result} + 'points': result + } # calculate revision_run (WW Runs Test) if needed if self.config.revision_run: From 7bbe59eda8e61ea136eb36155ac32b4902716659 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:39:19 -0700 Subject: [PATCH 31/53] move default config seetings that are not set by all of the plots to base config --- metplotpy/plots/config.py | 2 ++ metplotpy/plots/revision_box/revision_box_config.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index e6e07696..138644d1 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -45,8 +45,10 @@ def __init__(self, parameters): self.title_font = constants.DEFAULT_TITLE_FONT self.title_color = constants.DEFAULT_TITLE_COLOR self.xaxis = self.get_config_value('xaxis') + self.xaxis_reverse = False self.yaxis_1 = self.get_config_value('yaxis_1') self.yaxis_2 = self.get_config_value('yaxis_2') + self.sync_yaxes = False self.title = self.get_config_value('title') self.use_ee = self._get_bool('event_equal') self.indy_vals = self.get_config_value('indy_vals') diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index a9af9eb0..ffe1f84c 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -40,8 +40,6 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.dump_points_1 = self._get_bool('dump_points_1') self.create_html = self._get_bool('create_html') - self.sync_yaxes = False - self.xaxis_reverse = False ############################################## # caption parameters From 2c51ea4541e8e2e72a3d74508c2fb0f238f36859 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:46:15 -0700 Subject: [PATCH 32/53] Per #558, update histogram plot to use matplotlib instead of plotly --- metplotpy/plots/histogram/hist.py | 231 +++++------------------ metplotpy/plots/histogram/hist_config.py | 15 +- metplotpy/plots/histogram/hist_series.py | 5 +- metplotpy/plots/histogram/prob_hist.py | 19 +- metplotpy/plots/histogram/rank_hist.py | 2 +- metplotpy/plots/histogram/rel_hist.py | 2 +- 6 files changed, 58 insertions(+), 216 deletions(-) diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index 0474f1d2..06d44135 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -21,17 +21,14 @@ import numpy as np import pandas as pd - -import plotly.graph_objects as go -from plotly.subplots import make_subplots -from plotly.graph_objects import Figure +from matplotlib import pyplot as plt +from matplotlib.ticker import MultipleLocator from metplotpy.plots.histogram import hist_config -from metplotpy.plots.constants_plotly import PLOTLY_AXIS_LINE_COLOR, PLOTLY_AXIS_LINE_WIDTH, \ - PLOTLY_PAPER_BGCOOR +from metplotpy.plots.constants import MPL_DEFAULT_BAR_WIDTH from metplotpy.plots.histogram.hist_series import HistSeries -from metplotpy.plots.base_plot_plotly import BasePlot -from metplotpy.plots import util_plotly as util +from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots import util import metcalcpy.util.utils as utils from metcalcpy.event_equalize import event_equalize @@ -256,199 +253,58 @@ def _create_figure(self): """ self.logger.info(f"Begin creating the histogram figure: {datetime.now()}") # create and draw the plot - self.figure = self._create_layout() - self._add_xaxis() - self._add_yaxis() - self._add_legend() + _, ax = plt.subplots(figsize=(self.config_obj.plot_width, self.config_obj.plot_height)) + + wts_size_styles = self.get_weights_size_styles() + + self._add_title(ax, wts_size_styles['title']) + self._add_caption(plt, wts_size_styles['caption']) # add ser boxes - for series in self.series_list: - self._draw_series(series) + for idx, series in enumerate(self.series_list): + self._draw_series(ax, series, idx) + + # use x points from first series if indy label is not set + if not self.config_obj.indy_label: + self.config_obj.indy_label = self._get_x_points(self.series_list[0]) + + self._add_xaxis(ax, wts_size_styles['xlab']) + #if self._get_dtick(): + # ax.xaxis.set_major_locator(MultipleLocator(self._get_dtick())) + self._add_yaxis(ax, wts_size_styles['ylab']) # add custom lines if len(self.series_list) > 0: - self._add_lines( - self.config_obj - ) + self._add_lines(ax, self.config_obj) - self.logger.info(f"Finished creating the histogram figure: " - f"{datetime.now()}") + self._add_legend(ax) + plt.tight_layout() - def _draw_series(self, series: HistSeries) -> None: + self.logger.info(f"Finished creating the histogram figure: {datetime.now()}") + + def _draw_series(self, ax: plt.Axes, series: HistSeries, idx: int) -> None: """ Draws the formatted Bar on the plot :param series: Bar ser object with data and parameters """ - - # add the bar to plot - self.figure.add_trace( - go.Bar( - x=self._get_x_points(series), - y=series.series_points, - showlegend=self.config_obj.show_legend[series.idx] == 1, - name=self.config_obj.user_legends[series.idx], - marker_color=self.config_obj.colors_list[series.idx], - marker_line_color=self.config_obj.colors_list[series.idx] - ) + x_points = self._get_x_points(series) + y_points = series.series_points + + base = np.arange(len(x_points)) + n_visible_series = sum(1 for s in self.series_list if s.plot_disp) + n = max(n_visible_series, 1) + width = MPL_DEFAULT_BAR_WIDTH / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset + + ax.bar( + x=x_locs, height=y_points, width=width, align='center', + color=self.config_obj.colors_list[series.idx], + label=self.config_obj.user_legends[series.idx], ) - def _create_layout(self) -> Figure: - """ - Creates a new layout based on the properties from the config file - including plots size, annotation and title - - :return: Figure object - """ - self.logger.info(f"Creating the layout: {datetime.now()}") - - # create annotation - annotation = [ - {'text': util.apply_weight_style(self.config_obj.parameters['plot_caption'], - self.config_obj.parameters[ - 'caption_weight']), - 'align': 'left', - 'showarrow': False, - 'xref': 'paper', - 'yref': 'paper', - 'x': self.config_obj.parameters['caption_align'], - 'y': self.config_obj.caption_offset, - 'font': { - 'size': self.config_obj.caption_size, - 'color': self.config_obj.parameters['caption_col'] - } - }] - # create title - title = {'text': util.apply_weight_style(self.config_obj.title, - self.config_obj.parameters[ - 'title_weight']), - 'font': { - 'size': self.config_obj.title_font_size, - }, - 'y': self.config_obj.title_offset, - 'x': self.config_obj.parameters['title_align'], - 'xanchor': 'center', - 'xref': 'paper' - } - - # create a layout without y2 axis - fig = make_subplots(specs=[[{"secondary_y": False}]]) - - # add size, annotation, title - fig.update_layout( - width=self.config_obj.plot_width, - height=self.config_obj.plot_height, - margin=self.config_obj.plot_margins, - paper_bgcolor=PLOTLY_PAPER_BGCOOR, - annotations=annotation, - title=title, - plot_bgcolor=PLOTLY_PAPER_BGCOOR - ) - - self.logger.info(f"Finished creating the layout: {datetime.now()}") - return fig - - def _add_xaxis(self) -> None: - """ - Configures and adds x-axis to the plot - """ - self.logger.info(f"Configuring and adding the x-axis: {datetime.now()}") - - self.figure.update_xaxes(title_text=self.config_obj.xaxis, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=False, - zeroline=False, - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.x_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['xlab_offset']), - tickangle=self.config_obj.x_tickangle, - tickfont={'size': self.config_obj.x_tickfont_size}, - dtick=self._get_dtick() - ) - self.logger.info(f"Finished configuring and adding the x-axis:" - f" {datetime.now()}") - - def _add_yaxis(self) -> None: - """ - Configures and adds y-axis to the plot - """ - - self.logger.info(f"Configuring and adding the y-axis: {datetime.now()}") - self.figure.update_yaxes(title_text= - util.apply_weight_style(self.config_obj.yaxis_1, - self.config_obj.parameters[ - 'ylab_weight']), - secondary_y=False, - linecolor=PLOTLY_AXIS_LINE_COLOR, - linewidth=PLOTLY_AXIS_LINE_WIDTH, - showgrid=self.config_obj.grid_on, - zeroline=False, - ticks="inside", - gridwidth=self.config_obj.parameters['grid_lwd'], - gridcolor=self.config_obj.blended_grid_col, - automargin=True, - title_font={ - 'size': self.config_obj.y_title_font_size - }, - title_standoff=abs( - self.config_obj.parameters['ylab_offset']), - tickangle=self.config_obj.y_tickangle, - tickfont={'size': self.config_obj.y_tickfont_size} - ) - self.logger.info(f"Finished configuring and adding the y-axis:" - f" {datetime.now()}") - - def _add_legend(self) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure - """ - - self.logger.info(f"Adding the legend: {datetime.now()}") - self.figure.update_layout(legend={'x': self.config_obj.bbox_x, - 'y': self.config_obj.bbox_y, - 'xanchor': 'center', - 'yanchor': 'top', - 'bordercolor': - self.config_obj.legend_border_color, - 'borderwidth': - self.config_obj.legend_border_width, - 'orientation': - self.config_obj.legend_orientation, - 'font': { - 'size': self.config_obj.legend_size, - 'color': "black" - } - }) - self.logger.info(f"Finished adding the legend: {datetime.now()}") - - def write_html(self) -> None: - """ - Is needed - creates and saves the html representation of the plot WITHOUT - Plotly.js - """ - - self.logger.info(f"Begin writing html: {datetime.now()}") - - if self.config_obj.create_html is True: - # construct the file name from plot_filename - base_name, _ = os.path.splitext(self.get_config_value('plot_filename')) - html_name = f"{base_name}.html" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) - - self.logger.info(f"Finished writing html: {datetime.now()}") - def write_output_file(self) -> None: - """ - saves box points to the file - """ + """Saves box points to the file""" self.logger.info(f"Begin writing the output file: {datetime.now()}") # if points_path parameter doesn't exist, @@ -479,6 +335,5 @@ def write_output_file(self) -> None: map('{}\t'.format, [round(num, 6) for num in series.series_points])) file.writelines('\n') - file.close() self.logger.info(f"Finished writing the output file: {datetime.now()}") diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 5016e347..23437f82 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -16,9 +16,9 @@ import itertools -from ..config_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +from ..config import Config +from .. import constants +from .. import util import metcalcpy.util.utils as utils @@ -39,8 +39,8 @@ def __init__(self, parameters: dict) -> None: # plot parameters self.grid_on = self._get_bool('grid_on') - self.plot_width = self.calculate_plot_dimension('plot_width', 'pixels') - self.plot_height = self.calculate_plot_dimension('plot_height', 'pixels') + self.plot_width = self.calculate_plot_dimension('plot_width') + self.plot_height = self.calculate_plot_dimension('plot_height') self.dump_points_1 = self._get_bool('dump_points_1') self.create_html = self._get_bool('create_html') @@ -48,12 +48,12 @@ def __init__(self, parameters: dict) -> None: # caption parameters self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 + self.caption_offset = self.parameters['caption_offset'] * constants.DEFAULT_CAPTION_Y_OFFSET ############################################## # title parameters self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET + self.title_offset = 1.0 + abs(self.parameters['title_offset']) * constants.DEFAULT_TITLE_OFFSET self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## @@ -70,7 +70,6 @@ def __init__(self, parameters: dict) -> None: 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']) ############################################## # ser parameters diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 1e30222d..6333b9f1 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_plotly as util +import metplotpy.plots.util as util from ..series import Series @@ -106,8 +106,7 @@ def _create_series_points(self) -> list: else: series_points_results = self.series_data.loc[:, 'stat_value'].tolist() - logger.info(f"Finished creating the series points:" - f" {datetime.now()}") + logger.info(f"Finished creating the series points: {datetime.now()}") return series_points_results diff --git a/metplotpy/plots/histogram/prob_hist.py b/metplotpy/plots/histogram/prob_hist.py index c9e2ba57..e9839bd6 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_plotly as util +from metplotpy.plots import util class ProbHist(Hist): @@ -37,7 +37,8 @@ def _get_x_points(self, series: HistSeries) -> list: if len(ser.series_data) > 0: bin_size = ser.series_data['bin_size'][0] for i in range(1, int(1 / bin_size + 1)): - x_points.append(i * bin_size) + label = format(i * bin_size, '.2f').rstrip('0').rstrip('.') + x_points.append(label) return x_points def _get_dtick(self) -> Union[float, str]: @@ -63,19 +64,7 @@ def main(config_filename=None): Args: @param config_filename: default is None, the name of the custom config file to apply """ - params = util.get_params(config_filename) - try: - plot = ProbHist(params) - plot.save_to_file() - # plot.show_in_browser() - plot.write_html() - plot.write_output_file() - log_level = plot.get_config_value('log_level') - log_filename = plot.get_config_value('log_filename') - logger = util.get_common_logger(log_level, log_filename) - logger.info(f"Finished probability histogram: {datetime.now()}") - except ValueError as val_er: - print(val_er) + util.make_plot(config_filename, ProbHist) if __name__ == "__main__": diff --git a/metplotpy/plots/histogram/rank_hist.py b/metplotpy/plots/histogram/rank_hist.py index 0a54ee6f..b9bd61f8 100644 --- a/metplotpy/plots/histogram/rank_hist.py +++ b/metplotpy/plots/histogram/rank_hist.py @@ -15,7 +15,7 @@ from datetime import datetime from metplotpy.plots.histogram.hist import Hist -from metplotpy.plots import util_plotly as util +from metplotpy.plots import 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 5035fcd1..dba0cf45 100644 --- a/metplotpy/plots/histogram/rel_hist.py +++ b/metplotpy/plots/histogram/rel_hist.py @@ -15,7 +15,7 @@ from datetime import datetime -from metplotpy.plots import util_plotly as util +from metplotpy.plots import util from metplotpy.plots.histogram.hist import Hist from metplotpy.plots.histogram.hist_series import HistSeries From 369dad3208f99b56ba9a093b21f0b9eb815e60bf Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:31:55 -0700 Subject: [PATCH 33/53] add support for custom lines --- metplotpy/plots/bar/bar.py | 4 ++ metplotpy/plots/base_plot.py | 84 +++++++++++++++++++++++------------- metplotpy/plots/box/box.py | 4 ++ metplotpy/plots/config.py | 6 ++- 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 0e396d7c..8a3ab840 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -183,6 +183,10 @@ def _create_figure(self): self._add_legend(ax) + # add custom lines if lines are defined in config + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + plt.tight_layout() def _add_series(self, ax): diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 17306b93..c1942918 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -300,48 +300,26 @@ def remove_file(self): os.remove(image_name) @staticmethod - def add_horizontal_line(plt,y: float, line_properties: dict) -> None: + def add_horizontal_line(ax: plt.Axes, y: float, line_properties: dict) -> None: """Adds a horizontal line to the matplotlib plot - @param plt: Matplotlib pyplot object + @param ax: Matplotlib Axes 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) + ax.axhline(y=y, xmin=0, xmax=1, **line_properties) @staticmethod - def add_vertical_line(plt, x: float, line_properties: dict) -> None: + def add_vertical_line(ax: plt.Axes, x: float, line_properties: dict) -> None: """Adds a vertical line to the matplotlib plot - @param plt: Matplotlib pyplot object + @param ax: Matplotlib Axes object @param x x value for the line @param line_properties dictionary with line properties like color, width, dash @returns None """ - plt.axvline(x=x, ymin=0, ymax=1, **line_properties) - - @staticmethod - def add_horizontal_line(plt, y: float, line_properties: dict) -> None: - """Adds a horizontal line to the matplotlib plot - - @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) - - @staticmethod - def add_vertical_line(plt, x: float, line_properties: dict) -> None: - """Adds a vertical line to the matplotlib plot - - @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 - """ - plt.axvline(x=x, ymin=0, ymax=1, **line_properties) + ax.axvline(x=x, ymin=0, ymax=1, **line_properties) @staticmethod def get_array_dimensions(data): @@ -378,9 +356,9 @@ def _add_caption(self, plt, font_properties): ) def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: - """ - Creates a plot legend based on the properties from the config file - and attaches it to the initial Figure + """Creates a plot legend based on the properties from the config file. + Note: This should be called after adding the series, because the plot + labels need to be created before including them in the legend. """ orientation = "horizontal" if self.config_obj.legend_orientation == 'h' else "vertical" @@ -482,3 +460,47 @@ def _add_y2axis(self, ax: plt.Axes, fontproperties: FontProperties): ax_right.set_ylim(self.config_obj.parameters['y2lim']) return ax_right + + def _add_lines(self, ax: plt.Axes, 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: + @param ax - matplotlib Axes object + @param config_obj plot configuration object + @param x_points_index optional list of x-values that are used to create vertical line + """ + if not hasattr(config_obj, 'lines') or config_obj.lines is None: + return + + for line in config_obj.lines: + + # format line properties in format that matplotlib expects + line_properties = { + 'color': line['color'], + 'linewidth': line['line_width'], + 'linestyle': line['line_style'], + } + + # draw horizontal line + if line['type'] == 'horiz_line': + + y_position = line['position'] + self.add_horizontal_line(ax, y_position, line_properties) + + elif line['type'] == 'vert_line': + + # draw vertical line + x_position = line['position'] + try: + if x_points_index is not None: + ordered_indy_label = config_obj.create_list_by_plot_val_ordering( + config_obj.indy_label) + index = ordered_indy_label.index(line['position']) + x_position = x_points_index[index] + + self.add_vertical_line(ax, x_position, line_properties) + + except ValueError: + msg = f"Vertical line with position {x_position} cannot be created." + self.logger.warning(msg) + print(f"WARNING: {msg}") diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 3bba735a..6c69c4ce 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -211,6 +211,10 @@ def _create_figure(self): self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self._add_legend(ax, handles_and_labels) + # add custom lines if lines are defined in config + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + plt.tight_layout() self.logger.info(f"End creating the figure: {datetime.now()}") diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 138644d1..7db58895 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -868,7 +868,7 @@ def _get_lines(self) -> Union[list, None]: Args: Returns: - :return: list of lines properties or None + :return: list of lines properties or None """ # get property value from the parameters @@ -904,4 +904,8 @@ def _get_lines(self) -> Union[list, None]: print(f'WARNING: custom line width {line["line_width"]} is invalid') line['type'] = None + # convert line style to matplotlib format if necessary + if line['line_style'] in constants.LINESTYLE_BY_NAMES: + line['line_style'] = constants.LINESTYLE_BY_NAMES[line['line_style']] + return lines From d35fe90b676bcc0a945c8190e326e090e60ab693 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:32:25 -0700 Subject: [PATCH 34/53] fix number of columns in legend to match original R plot --- test/box/custom_box.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index 76bb0488..8f034170 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -65,7 +65,7 @@ legend_box: o legend_inset: x: 0.0 y: -0.25 -legend_ncol: 3 +legend_ncol: 1 legend_size: 0.8 line_type: None list_stat_1: From 4f33a79638d2ebb8afd7a408459ec55a60c9c156 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:32:44 -0700 Subject: [PATCH 35/53] turn off grid to match original R plot --- test/histogram/prob_hist.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/histogram/prob_hist.yaml b/test/histogram/prob_hist.yaml index f4610ca0..2b1f2c01 100644 --- a/test/histogram/prob_hist.yaml +++ b/test/histogram/prob_hist.yaml @@ -16,7 +16,7 @@ fixed_vars_vals_input: {} grid_col: '#cccccc' grid_lty: 3 grid_lwd: 1 -grid_on: 'True' +grid_on: 'False' grid_x: listX indy_label: [] indy_plot_val: [] From fc2eec79adbd56e65d20808377413dbcaa48e32d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:28:03 -0700 Subject: [PATCH 36/53] Refactored config consistency checking to be consistent across multiple plots. Handle logging and error raising within helper function. Provided a default list of lists to check against the number of series, with the option to override and change per plot as needed. Improve error messages to describe with specific config option has an inconsistent number of values compared to the number of series to assist with debugging --- metplotpy/plots/bar/bar.py | 13 +---- metplotpy/plots/bar/bar_config.py | 34 ------------ metplotpy/plots/box/box.py | 10 +--- metplotpy/plots/box/box_config.py | 32 ----------- metplotpy/plots/config.py | 53 +++++++++++++++++++ metplotpy/plots/histogram/hist.py | 15 +----- metplotpy/plots/histogram/hist_config.py | 32 ----------- metplotpy/plots/revision_box/revision_box.py | 9 +--- .../plots/revision_box/revision_box_config.py | 32 ----------- 9 files changed, 58 insertions(+), 172 deletions(-) diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 8a3ab840..a90da4c8 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -54,18 +54,7 @@ def __init__(self, parameters: dict) -> None: self.logger = self.config_obj.logger self.logger.info(f"Start bar plot: {datetime.now()}") # Check that we have all the necessary settings for each series - self.logger.info("Consistency checking of config settings for colors, " - "legends, etc.") - is_config_consistent = self.config_obj.config_consistency_check() - if not is_config_consistent: - value_error_msg = ("ValueError: The number of series defined by series_val_1 and " - "derived curves is inconsistent with the number of " - "settings required for describing each series. Please " - "check the number of your configuration file's " - "plot_i, plot_disp, series_order, user_legend, show_legend and " - "colors settings.") - self.logger.error(value_error_msg) - raise ValueError(value_error_msg) + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.logger.info(f"Begin reading input data: {datetime.now()}") diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 326cccae..cd012ec8 100644 --- a/metplotpy/plots/bar/bar_config.py +++ b/metplotpy/plots/bar/bar_config.py @@ -179,40 +179,6 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - lists_to_check = { - "plot_disp": self.plot_disp, - "series_ordering": self.series_ordering, - "colors_list": self.colors_list, - "user_legends": self.user_legends, - } - status = True - for name, list_to_check in lists_to_check.items(): - - if len(list_to_check) == self.num_series: - continue - - self.logger.error( - f"{name} ({len(list_to_check)}) does not match number of series ({self.num_series})" - ) - status = False - - return status - 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. diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 6c69c4ce..7321efef 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -60,15 +60,7 @@ def __init__(self, parameters): self.logger.info(f"Start bar plot at {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - self.logger.info("Checking consistency of user_legends, colors, etc...") - if not is_config_consistent: - raise ValueError("The number of series defined by series_val_1/2 and derived curves is" - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index bdc9eea3..b4c0cae5 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -208,38 +208,6 @@ def _get_plot_stat(self) -> str: " Supported values are sum, mean, and median.") return stat_to_plot - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends: - status = True - return status - 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. diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 7db58895..668a4810 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -17,6 +17,7 @@ import itertools from typing import Union +from datetime import datetime import metcalcpy.util.utils as utils import metplotpy.plots.util @@ -909,3 +910,55 @@ def _get_lines(self) -> Union[list, None]: line['line_style'] = constants.LINESTYLE_BY_NAMES[line['line_style']] return lines + + def config_consistency_check(self) -> None: + """Checks that the number of settings defined for + plot_disp, series_ordering, colors_list, user_legends, and show_legend + are consistent with number of series. + + @raises ValueError if any of settings are inconsistent with the + number of series (as defined by the cross product of the model + and vx_mask defined in the series_val_1 setting) + """ + lists_to_check = { + "plot_disp": self.plot_disp, + "series_ordering": self.series_ordering, + "colors_list": self.colors_list, + "user_legends": self.user_legends, + "show_legend": self.show_legend, + } + self._config_compare_lists_to_num_series(lists_to_check) + + def _config_compare_lists_to_num_series(self, lists_to_check: dict) -> list: + """ + Checks that the number of settings defined for lists are consistent + with the number of series to plot. + + Args: + @param lists_to_check: dictionary with name of list as key and + actual list to check as value. + + @raises ValueError if any settings are inconsistent with the number of series + """ + self.logger.info(f"Checking consistency of config settings relative to number of series {datetime.now()}") + + # Determine the number of series based on the number of + # permutations from the series_var setting in the config file + error_messages = [] + for name, list_to_check in lists_to_check.items(): + + if len(list_to_check) == self.num_series: + continue + + error_messages.append(f"{name} ({len(list_to_check)}) does not match number of series ({self.num_series})") + + if error_messages: + msg = ( + "The number of series defined by series_val_1/2 and derived curves is " + "inconsistent with the number of settings required for describing each series." + ) + msg += "\n" + "\n".join(error_messages) + self.logger.error(msg) + raise ValueError(msg) + + self.logger.info(f"Config consistency check completed successfully: {datetime.now()}") diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index 06d44135..8e4e9e88 100644 --- a/metplotpy/plots/histogram/hist.py +++ b/metplotpy/plots/histogram/hist.py @@ -66,19 +66,8 @@ def __init__(self, parameters: dict) -> None: f" {datetime.now()}") # Check that we have all the necessary settings for each ser - self.logger.info(f"Performing consistency check for settings in config " - f"file: {datetime.now()}") - is_config_consistent = self.config_obj._config_consistency_check() - self.logger.info(f"Finished with consistency check: {datetime.now()}") - if not is_config_consistent: - error_msg = ("The number of ser defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each ser. Please check" - " the number of your configuration file's " - " plot_disp, series_order, user_legend, show_legend" - " colors settings.") - self.logger.error(f"ValueError: {error_msg}") - raise ValueError(error_msg) + self.config_obj.config_consistency_check() + # Read in input data, location specified in config file self.input_df = self._read_input_data() diff --git a/metplotpy/plots/histogram/hist_config.py b/metplotpy/plots/histogram/hist_config.py index 23437f82..932fe4ee 100644 --- a/metplotpy/plots/histogram/hist_config.py +++ b/metplotpy/plots/histogram/hist_config.py @@ -127,38 +127,6 @@ def _get_plot_disp(self) -> list: return self.create_list_by_series_ordering(plot_display_bools) - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for plot_ci, - plot_disp, series_order, user_legend colors, and series_symbols - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - ser (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of ser based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for ser - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends: - status = True - return status - def get_series_y(self) -> list: """ Creates an array of ser components (excluding derived) tuples for the specified y-axis diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index fa9eb6a8..b26ed50f 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -51,14 +51,7 @@ def __init__(self, parameters: dict) -> None: self.logger.info(f"Begin revision box plotting: {datetime.now()}") # Check that we have all the necessary settings for each series - is_config_consistent = self.config_obj._config_consistency_check() - if not is_config_consistent: - raise ValueError("The number of series defined by series_val_1 is" - " inconsistent with the number of settings" - " required for describing each series. Please check" - " the number of your configuration file's plot_i," - " plot_disp, series_order, user_legend," - " colors, show_legend and series_symbols settings.") + self.config_obj.config_consistency_check() # Read in input data, location specified in config file self.input_df = self._read_input_data() diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index ffe1f84c..b8395436 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -226,35 +226,3 @@ def _get_user_legends(self, legend_label_type: str = '') -> list: legend_list.append(all_user_legends[idx]) return self.create_list_by_series_ordering(legend_list) - - def _config_consistency_check(self) -> bool: - """ - Checks that the number of settings defined for - plot_disp, series_order, user_legend colors - are consistent. - - Args: - - Returns: - True if the number of settings for each of the above - settings is consistent with the number of - series (as defined by the cross product of the model - and vx_mask defined in the series_val_1 setting) - - """ - # Determine the number of series based on the number of - # permutations from the series_var setting in the - # config file - - # Numbers of values for other settings for series - num_plot_disp = len(self.plot_disp) - num_series_ord = len(self.series_ordering) - num_colors = len(self.colors_list) - num_legends = len(self.user_legends) - status = False - - if self.num_series == num_plot_disp == \ - num_series_ord == num_colors \ - == num_legends: - status = True - return status From 58eb4dda630f444bfb515da247c035655a62ca4e Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:31:57 -0700 Subject: [PATCH 37/53] after updating config consistency check logic, fix incorrect yaml values for show_legend that were being interpreted as a list of single characters instead of boolean values --- test/bar/custom_bar.yaml | 2 +- test/histogram/prob_hist.yaml | 2 +- test/histogram/rank_hist.yaml | 4 +++- test/histogram/rel_hist.yaml | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/bar/custom_bar.yaml b/test/bar/custom_bar.yaml index c9787f3e..65b03581 100644 --- a/test/bar/custom_bar.yaml +++ b/test/bar/custom_bar.yaml @@ -143,4 +143,4 @@ plot_filename: !ENV '${TEST_OUTPUT}/bar.png' #log_level: WARNING show_legend: - True -- True \ No newline at end of file +- True diff --git a/test/histogram/prob_hist.yaml b/test/histogram/prob_hist.yaml index 2b1f2c01..40ae551d 100644 --- a/test/histogram/prob_hist.yaml +++ b/test/histogram/prob_hist.yaml @@ -96,4 +96,4 @@ ytlab_size: 1 # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True \ No newline at end of file +- True \ No newline at end of file diff --git a/test/histogram/rank_hist.yaml b/test/histogram/rank_hist.yaml index f1899b45..276742c1 100644 --- a/test/histogram/rank_hist.yaml +++ b/test/histogram/rank_hist.yaml @@ -110,4 +110,6 @@ ytlab_size: 1 # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True \ No newline at end of file +- True +- True +- True \ No newline at end of file diff --git a/test/histogram/rel_hist.yaml b/test/histogram/rel_hist.yaml index e6806307..181e855f 100644 --- a/test/histogram/rel_hist.yaml +++ b/test/histogram/rel_hist.yaml @@ -99,4 +99,4 @@ ytlab_size: 1 # Debug and info log level will produce more log output. #log_level: WARNING show_legend: - -True \ No newline at end of file +- True \ No newline at end of file From 94383dd35a685b3e8ef36b20a88295e919bf6fae Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:34:56 -0700 Subject: [PATCH 38/53] added mean line, adjusted median line, and added logic to more closely match plotly version in terms of outliers --- metplotpy/plots/base_plot.py | 7 +++++- metplotpy/plots/box/box.py | 41 +++++++++++++++++++------------ metplotpy/plots/box/box_config.py | 15 +++++------ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index c1942918..c5cccc26 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -427,11 +427,15 @@ def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: """ Creates x2axis based on the properties from the config file. + Note: This function is based on logic from individual plots that show number of stats (n_stats) + on the top x-axis. This will need to be modified if other plots display a 2nd x-axis + with other information. + Note: this function may need to be called after adding the series, because some plots add ticks that will conflict with the explicit x ticks set in this function. Calliing this after will override the ticks and prevent a conflict. - :param n_stats: - labels for the axis + :param n_stats labels for the axis """ if not self.config_obj.show_nstats: return @@ -441,6 +445,7 @@ def _add_x2axis(self, ax, n_stats, fontproperties: FontProperties) -> None: labelpad=abs(self.config_obj.parameters['x2lab_offset']) * constants.PIXELS_TO_POINTS) current_locs = ax.get_xticks() ax_top.set_xticks(current_locs, n_stats, size=self.config_obj.x2_tickfont_size) + # this doesn't appear to be working to add ticks at the top ax_top.tick_params(axis="x", direction="in", labelrotation=self.config_obj.x2_tickangle) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 7321efef..8955eeeb 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -96,11 +96,9 @@ def _read_input_data(self): Returns: """ - self.config_obj.logger.info(f"Begin reading input data:" - f" {datetime.now()}") + self.config_obj.logger.info(f"Begin reading input data: {datetime.now()}") file = self.config_obj.parameters['stat_input'] - self.config_obj.logger.info(f"Finish reading input data:" - f" {datetime.now()}") + self.config_obj.logger.info(f"Finish reading input data: {datetime.now()}") return pd.read_csv(file, sep='\t', header='infer', float_precision='round_trip') def _create_series(self, input_data): @@ -189,13 +187,6 @@ def _create_figure(self): self._add_xaxis(ax, wts_size_styles['xlab']) self._add_yaxis(ax, wts_size_styles['ylab']) - # add custom lines - # if len(self.series_list) > 0: - # self._add_lines( - # self.config_obj, - # sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) - # ) - # add x2 axis if wts_size_styles.get('x2lab'): self._add_x2axis(ax, n_stats, wts_size_styles['x2lab']) @@ -236,15 +227,33 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): # Group your 'stat_value' data by 'indy_var' categories first data_to_plot, x_locs, width = self._get_data_to_plot_and_x_locs(series, idx) - # data_to_plot = [group_data for name, group_data in - # series.series_data.groupby(self.config_obj.indy_var)['stat_value']] - plot_ax = ax if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: plot_ax = ax2 - boxplot = plot_ax.boxplot(data_to_plot, positions=x_locs, patch_artist=True, widths=width, - label=self.config_obj.user_legends[series.idx]) + # Define properties for median and mean lines + median_props = { + 'color': 'black', + 'linewidth': 1, + } + mean_props = { + 'linestyle': '--', + 'color': 'black', + 'linewidth': 1, + } + + boxplot = plot_ax.boxplot(data_to_plot, positions=x_locs, + patch_artist=True, + widths=width, + label=self.config_obj.user_legends[series.idx], + showmeans=self.config_obj.box_avg, + meanline=self.config_obj.box_avg, + medianprops=median_props, + meanprops=mean_props, + whis=self.config_obj.whis, + showfliers=self.config_obj.showfliers, + ) + for box in boxplot['boxes']: box.set_facecolor(series.color) diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index b4c0cae5..b43d8b43 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -125,17 +125,18 @@ def __init__(self, parameters: dict) -> None: self.legend_orientation = 'h' self.legend_border_color = "black" - box_outline = self._get_bool('box_outline') - if box_outline is True: - self.boxpoints = 'outliers' - else: - self.boxpoints = False + # Default Matplotlib values for whiskers + self.whis = 1.5 + self.showfliers = True + self.box_avg = self._get_bool('box_avg') self.box_notch = self._get_bool('box_notch') self.box_pts = self._get_bool('box_pts') - if self.box_pts is True: - self.boxpoints = 'all' + if self.box_pts: + self.whis = [0, 100] + elif not self._get_bool('box_outline'): + self.showfliers = False def _get_plot_disp(self) -> list: """ From c922238b06e74c7eb183232de5d32dc53284303c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:35:28 -0700 Subject: [PATCH 39/53] adjusted config value to stretch whisker to outlier point to more closely match plotly version --- test/box/custom_box.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index 8f034170..d3cfe0e1 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -3,7 +3,7 @@ box_avg: 'True' box_boxwex: 0.2 box_notch: 'False' box_outline: 'True' -box_pts: 'False' +box_pts: 'True' caption_align: 0.0 caption_col: '#333333' caption_offset: 8.0 From 3879114edeefd3e794663fe850a81121923c5cdd Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:59:51 -0700 Subject: [PATCH 40/53] improve handling of situation when fcst vars are not set in config --- metplotpy/plots/config.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index 668a4810..fc3a8f08 100644 --- a/metplotpy/plots/config.py +++ b/metplotpy/plots/config.py @@ -424,7 +424,10 @@ def get_fcst_vars_dict(self, index: int) -> dict: if index not in (1, 2): return {} - return self.get_config_value(f'fcst_var_val_{index}') + fcst_dict = self.get_config_value(f'fcst_var_val_{index}') + if fcst_dict is None: + return {} + return fcst_dict def get_fcst_vars_keys(self, index: int) -> list: """Retrieve a list of keys from the fcst_var_val_{index} variable from the config. @@ -438,7 +441,10 @@ def get_fcst_vars_keys(self, index: int) -> list: used to subset the input data that corresponds to a particular series. """ - return list(self.get_fcst_vars_dict(index).keys()) + fcst_vars_dict = self.get_fcst_vars_dict(index) + if fcst_vars_dict is None: + return [] + return list(fcst_vars_dict.keys()) def _get_series_val_names(self) -> list: """ From 13cb39da186fe9c703f24e77366f9d4e2889a27c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:00:06 -0700 Subject: [PATCH 41/53] use explicit function to get fcst var keys instead of dict --- metplotpy/plots/histogram/hist_series.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metplotpy/plots/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 6333b9f1..d404a3f6 100644 --- a/metplotpy/plots/histogram/hist_series.py +++ b/metplotpy/plots/histogram/hist_series.py @@ -53,8 +53,8 @@ def _create_all_fields_values_no_indy(self) -> dict: for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self.config._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self.config._get_fcst_vars(1).keys()) + if self.config.get_fcst_vars_keys(1): + all_fields_values['fcst_var'] = self.config.get_fcst_vars_keys(1) all_fields_values_no_indy[1] = all_fields_values return all_fields_values_no_indy From 53140c3ea2b9c1b9968449a68b6b5011f1674326 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:36:53 -0700 Subject: [PATCH 42/53] adjust settings for whis and showfliers to more closely match the plotly boxpoints settings --- metplotpy/plots/box/box_config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index b43d8b43..d4f267db 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -134,9 +134,15 @@ def __init__(self, parameters: dict) -> None: self.box_pts = self._get_bool('box_pts') if self.box_pts: + self.showfliers = False + self.boxpoints = 'all' + elif self._get_bool('box_outline'): + self.whis = 2.5 + self.boxpoints = 'outliers' + else: self.whis = [0, 100] - elif not self._get_bool('box_outline'): self.showfliers = False + self.boxpoints = False def _get_plot_disp(self) -> list: """ From ef51ddf7337530f2ea1d96b979157368608a1d44 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:37:39 -0700 Subject: [PATCH 43/53] change value back to original because plot should create the same formatted box with the old settings --- test/box/custom_box.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index d3cfe0e1..8f034170 100644 --- a/test/box/custom_box.yaml +++ b/test/box/custom_box.yaml @@ -3,7 +3,7 @@ box_avg: 'True' box_boxwex: 0.2 box_notch: 'False' box_outline: 'True' -box_pts: 'True' +box_pts: 'False' caption_align: 0.0 caption_col: '#333333' caption_offset: 8.0 From d0e3b56cfaf8288302917c7d78ffbc7dda431a42 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:38:19 -0700 Subject: [PATCH 44/53] updates to get revision box plots to render (more) correctly and so that RevisionBoxConfig can inherit from BoxConfig --- metplotpy/plots/base_plot.py | 12 +- metplotpy/plots/box/box.py | 9 +- metplotpy/plots/box/box_config.py | 53 ++++-- metplotpy/plots/revision_box/revision_box.py | 14 +- .../plots/revision_box/revision_box_config.py | 177 ++---------------- 5 files changed, 75 insertions(+), 190 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index c5cccc26..d12efd06 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -205,7 +205,10 @@ def get_weights_size_styles(self): weights_size_styles['ylab'] = ylab_property # For x2axis label if set - if hasattr(self.config_obj, 'x2lab_weight') and hasattr(self.config_obj, 'x2_title_font_size'): + if (hasattr(self.config_obj, 'x2lab_weight') + and self.config_obj.x2lab_weight is not None + and hasattr(self.config_obj, 'x2_title_font_size') + and self.config_obj.x2_title_font_size is not None): x2lab_property= FontProperties() x2lab_property.set_size(self.config_obj.x2_title_font_size) x2lab_style, x2lab_wt = self.config_obj.x2lab_weight @@ -215,7 +218,10 @@ def get_weights_size_styles(self): # For y2axis label if set - if hasattr(self.config_obj, 'y2lab_weight') and hasattr(self.config_obj, 'y2_title_font_size'): + if (hasattr(self.config_obj, 'y2lab_weight') + and self.config_obj.y2lab_weight is not None + and hasattr(self.config_obj, 'y2_title_font_size') + and self.config_obj.y2_title_font_size is not None): y2lab_property= FontProperties() y2lab_property.set_size(self.config_obj.y2_title_font_size) y2lab_style, y2lab_wt = self.config_obj.y2lab_weight @@ -403,7 +409,7 @@ def _add_xaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: linestyle='-', linewidth=self.config_obj.parameters['grid_lwd']) ax.set_axisbelow(True) - if self.config_obj.xaxis_reverse is True: + if self.config_obj.xaxis_reverse: ax.invert_xaxis() def _add_yaxis(self, ax: plt.Axes, fontproperties: FontProperties) -> None: diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 8955eeeb..7a524ee3 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -194,14 +194,17 @@ def _create_figure(self): self._sync_yaxes(ax, ax_y2, yaxis_min, yaxis_max) self._add_legend(ax, handles_and_labels) - # add custom lines if lines are defined in config - if len(self.series_list) > 0: - self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + self._add_custom_lines(ax) plt.tight_layout() self.logger.info(f"End creating the figure: {datetime.now()}") + def _add_custom_lines(self, ax): + # add custom lines if lines are defined in config + if len(self.series_list) > 0: + self._add_lines(ax, self.config_obj, self.config_obj.indy_vals) + def _sync_yaxes(self, ax, ax2, yaxis_min: Union[float, None], yaxis_max: Union[float, None]): if not self.config_obj.sync_yaxes: return diff --git a/metplotpy/plots/box/box_config.py b/metplotpy/plots/box/box_config.py index d4f267db..dc788ceb 100644 --- a/metplotpy/plots/box/box_config.py +++ b/metplotpy/plots/box/box_config.py @@ -73,12 +73,18 @@ def __init__(self, parameters: dict) -> None: self.y_tickfont_size = self.parameters['ytlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## - # y2-axis parameters - self.y2_title_font_size = self.parameters['y2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.y2_tickangle = self.parameters['y2tlab_orient'] + # y2-axis parameters (optional - not used for revision box) + self.y2_title_font_size = None + if self.parameters.get('y2lab_size'): + self.y2_title_font_size = self.parameters['y2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.y2_tickangle = self.parameters['y2tlab_orient'] if self.parameters.get('y2tlab_orient') else None if self.y2_tickangle in constants.YAXIS_ORIENTATION.keys(): self.y2_tickangle = constants.YAXIS_ORIENTATION[self.y2_tickangle] - self.y2_tickfont_size = self.parameters['y2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.y2_tickfont_size = None + if self.parameters.get('y2tlab_size'): + self.y2_tickfont_size = self.parameters['y2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## # x-axis parameters @@ -89,12 +95,18 @@ def __init__(self, parameters: dict) -> None: self.x_tickfont_size = self.parameters['xtlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## - # x2-axis parameters - self.x2_title_font_size = self.parameters['x2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE - self.x2_tickangle = self.parameters['x2tlab_orient'] + # x2-axis parameters (optional - not used for revision box) + self.x2_title_font_size = None + if self.parameters.get('x2lab_size'): + self.x2_title_font_size = self.parameters['x2lab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.x2_tickangle = self.parameters['x2tlab_orient'] if self.parameters.get('x2tlab_orient') else None if self.x2_tickangle in constants.XAXIS_ORIENTATION.keys(): self.x2_tickangle = constants.XAXIS_ORIENTATION[self.x2_tickangle] - self.x2_tickfont_size = self.parameters['x2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE + + self.x2_tickfont_size = None + if self.parameters.get('x2tlab_size'): + self.x2_tickfont_size = self.parameters['x2tlab_size'] + constants.DEFAULT_TITLE_FONTSIZE ############################################## # series parameters @@ -288,15 +300,18 @@ def get_series_y(self, axis: int) -> list: :param axis: y-axis (1 or 2) :return: an array of series components tuples """ - all_fields_values_orig = self.get_config_value('series_val_' + str(axis)).copy() + if not self.get_config_value(f'series_val_{axis}'): + return [] + + all_fields_values_orig = self.get_config_value(f'series_val_{axis}').copy() all_fields_values = {} for x in reversed(list(all_fields_values_orig.keys())): all_fields_values[x] = all_fields_values_orig.get(x) - if self._get_fcst_vars(axis): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(axis).keys()) + if self.get_fcst_vars_keys(axis): + all_fields_values['fcst_var'] = self.get_fcst_vars_keys(axis) - all_fields_values['stat_name'] = self.get_config_value('list_stat_' + str(axis)) + all_fields_values['stat_name'] = self.get_config_value(f'list_stat_{axis}') return utils.create_permutations_mv(all_fields_values, 0) def _get_all_series_y(self, axis: int) -> list: @@ -327,9 +342,9 @@ def calculate_number_of_series(self) -> int: """ # Retrieve the lists from the series_val_1 dictionary series_vals_list = self.series_vals_1.copy() - if isinstance(self.fcst_var_val_1, list) is True: + if isinstance(self.fcst_var_val_1, list): fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: + elif isinstance(self.fcst_var_val_1, dict): fcst_vals = list(self.fcst_var_val_1.values()) else: fcst_vals = list() @@ -343,9 +358,9 @@ def calculate_number_of_series(self) -> int: if self.series_vals_2: series_vals_list_2 = self.series_vals_2.copy() - if isinstance(self.fcst_var_val_2, list) is True: + if isinstance(self.fcst_var_val_2, list): fcst_vals_2 = self.fcst_var_val_2 - elif isinstance(self.fcst_var_val_2, dict) is True: + elif isinstance(self.fcst_var_val_2, dict): fcst_vals_2 = list(self.fcst_var_val_2.values()) else: fcst_vals_2 = list() @@ -356,7 +371,9 @@ def calculate_number_of_series(self) -> int: total = len(permutations) # add derived - total = total + len(self.get_config_value('derived_series_1')) - total = total + len(self.get_config_value('derived_series_2')) + if self.get_config_value('derived_series_1'): + total = total + len(self.get_config_value('derived_series_1')) + if self.get_config_value('derived_series_2'): + total = total + len(self.get_config_value('derived_series_2')) return total diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index b26ed50f..8d37863a 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -14,6 +14,8 @@ import re from datetime import datetime +import numpy as np + from metplotpy.plots.base_plot import BasePlot from metplotpy.plots.box.box import Box @@ -100,7 +102,7 @@ def _create_series(self, input_data): series_list = [] # add series for y1 axis - for i, name in enumerate(self.config_obj.get_series_y()): + for i, name in enumerate(self.config_obj.get_series_y(1)): series_obj = RevisionBoxSeries(self.config_obj, i, input_data, series_list, name) series_list.append(series_obj) @@ -144,6 +146,9 @@ def _create_figure(self): self.config_obj.plot_caption = annotation_text_all + # set the x-axis labels to match the user legends + self.config_obj.indy_label = self.config_obj.user_legends + super()._create_figure() # add custom lines @@ -177,8 +182,13 @@ def _create_figure(self): self.logger.info(f"Finished creating figure: {datetime.now()}") + def _add_custom_lines(self, ax): + return + def _get_data_to_plot_and_x_locs(self, series, idx): - return [series.series_points['points']['stat_value'].tolist()], None, MPL_DEFAULT_BOX_WIDTH + base = np.arange(len(self.config_obj.indy_label)) + x_locs = [base[idx]] + return series.series_points['points']['stat_value'].dropna().values, x_locs, MPL_DEFAULT_BOX_WIDTH # def _draw_series(self, series: RevisionBoxSeries) -> None: # """ diff --git a/metplotpy/plots/revision_box/revision_box_config.py b/metplotpy/plots/revision_box/revision_box_config.py index b8395436..f32898a2 100644 --- a/metplotpy/plots/revision_box/revision_box_config.py +++ b/metplotpy/plots/revision_box/revision_box_config.py @@ -14,14 +14,14 @@ """ import itertools -from ..config import Config +from ..box.box_config import BoxConfig from .. import constants as constants from .. import util import metcalcpy.util.utils as utils -class RevisionBoxConfig(Config): +class RevisionBoxConfig(BoxConfig): def __init__(self, parameters: dict) -> None: """ Reads in the plot settings from a revision box plot config file. @@ -32,172 +32,21 @@ def __init__(self, parameters: dict) -> None: super().__init__(parameters) - ############################################## - # Optional setting, indicates *where* to save the dump_points_1 file - # used by METviewer - self.points_path = self.get_config_value('points_path') - - # plot parameters - self.dump_points_1 = self._get_bool('dump_points_1') - self.create_html = self._get_bool('create_html') - - ############################################## - # caption parameters - self.caption_size = int(constants.DEFAULT_CAPTION_FONTSIZE - * self.get_config_value('caption_size')) - self.caption_offset = self.parameters['caption_offset'] - 3.1 - - ############################################## - # title parameters - self.title_font_size = self.parameters['title_size'] * constants.DEFAULT_TITLE_FONT_SIZE - self.title_offset = self.parameters['title_offset'] * constants.DEFAULT_TITLE_OFFSET - self.y_title_font_size = self.parameters['ylab_size'] + constants.DEFAULT_TITLE_FONTSIZE - - ############################################## - # y-axis parameters - 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.DEFAULT_TITLE_FONTSIZE - - ############################################## - # x-axis parameters - self.x_title_font_size = self.parameters['xlab_size'] + constants.DEFAULT_TITLE_FONTSIZE - 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.DEFAULT_TITLE_FONTSIZE - - ############################################## - # series parameters - self.series_ordering = self.get_config_value('series_order') - # Make the series ordering zero-based - self.series_ordering_zb = [sorder - 1 for sorder in self.series_ordering] - self.plot_disp = self._get_plot_disp() - self.colors_list = self._get_colors() - self.all_series_y1 = self.get_series_y() - self.num_series = self.calculate_number_of_series() - self.show_legend = self._get_show_legend() - - ############################################## - # legend parameters - self.user_legends = self._get_user_legends() - self.bbox_x = 0.5 + self.parameters['legend_inset']['x'] - self.bbox_y = -0.12 + self.parameters['legend_inset']['y'] + 0.25 - self.legend_size = int(constants.DEFAULT_LEGEND_FONTSIZE * self.parameters['legend_size']) - if self.parameters['legend_box'].lower() == 'n': - self.legend_border_width = 0 # Don't draw a box around legend labels - else: - self.legend_border_width = 2 # Enclose legend labels in a box - - if self.parameters['legend_ncol'] == 1: - self.legend_orientation = 'v' - else: - self.legend_orientation = 'h' - self.legend_border_color = "black" - - box_outline = self._get_bool('box_outline') - if box_outline is True: - self.boxpoints = 'outliers' - else: - self.boxpoints = False - self.box_avg = self._get_bool('box_avg') - self.box_notch = self._get_bool('box_notch') - - self.box_pts = self._get_bool('box_pts') - if self.box_pts is True: - self.boxpoints = 'all' + # override values set in BoxConfig that are not used by revision box + self.plot_stat = None + self.show_nstats = None + self.dump_points_2 = None + self.vert_plot = None + self.xaxis_reverse = None + self.sync_yaxes = None + self.all_series_y2 = None + + # set values specific to RevisionBox not set in BoxConfig self.revision_ac = self._get_bool('revision_ac') self.revision_run = self._get_bool('revision_run') self.indy_stagger = self._get_bool('indy_stagger_1') - 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.copy() - if isinstance(self.fcst_var_val_1, list) is True: - fcst_vals = self.fcst_var_val_1 - elif isinstance(self.fcst_var_val_1, dict) is True: - fcst_vals = list(self.fcst_var_val_1.values()) - fcst_vals_flat = [item for sublist in fcst_vals for item in sublist] - series_vals_list.append(fcst_vals_flat) - - # 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 = list(itertools.product(*series_vals_list)) - total = len(permutations) - - return total - - def _get_plot_disp(self) -> list: - """ - Retrieve the values that determine whether to display a particular series - and convert them to bool if needed - - Args: - - Returns: - A list of boolean values indicating whether or not to - display the corresponding series - """ - - plot_display_config_vals = self.get_config_value('plot_disp') - plot_display_bools = [] - for val in plot_display_config_vals: - if isinstance(val, bool): - plot_display_bools.append(val) - - if isinstance(val, str): - plot_display_bools.append(val.upper() == 'TRUE') - - return self.create_list_by_series_ordering(plot_display_bools) - - def get_series_y(self) -> list: - """ - Creates an array of series components (excluding derived) tuples for the y-axis - :param axis: - :return: an array of series components tuples - """ - all_fields_values_orig = self.get_config_value('series_val_1').copy() - all_fields_values = {} - for x in reversed(list(all_fields_values_orig.keys())): - all_fields_values[x] = all_fields_values_orig.get(x) - - if self._get_fcst_vars(1): - all_fields_values['fcst_var'] = list(self._get_fcst_vars(1).keys()) - - all_fields_values['stat_name'] = self.get_config_value('list_stat_1') - return utils.create_permutations_mv(all_fields_values, 0) - - def _get_fcst_vars(self, index): - """ - 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 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. - - """ - fcst_var_val_dict = self.get_config_value('fcst_var_val_1') - if not fcst_var_val_dict: - fcst_var_val_dict = {} - - return fcst_var_val_dict - 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. @@ -217,7 +66,7 @@ def _get_user_legends(self, legend_label_type: str = '') -> list: legend_list = [] # create legend list for y-axis series - for idx, ser_components in enumerate(self.get_series_y()): + for idx, ser_components in enumerate(self.get_series_y(1)): if idx >= len(all_user_legends) or all_user_legends[idx].strip() == '': # user did not provide the legend - create it legend_list.append(' '.join(map(str, ser_components))) From 63e91a7461d8b0f55f907aa2ad0e591b7c2eba48 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:43:37 -0700 Subject: [PATCH 45/53] remove commented code and out-of-date comments --- metplotpy/plots/box/box.py | 47 ---------- metplotpy/plots/revision_box/revision_box.py | 99 -------------------- 2 files changed, 146 deletions(-) diff --git a/metplotpy/plots/box/box.py b/metplotpy/plots/box/box.py index 7a524ee3..fb359f25 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -77,11 +77,6 @@ def __init__(self, parameters): # line width, and criteria needed to subset the input dataframe. self.series_list = self._create_series(self.input_df) - # create figure - # pylint:disable=assignment-from-no-return - # Need to have a self.figure that we can pass along to - # the methods in base_plot.py (BasePlot class methods) to - # create binary versions of the plot. self._create_figure() def _read_input_data(self): @@ -261,48 +256,6 @@ def _draw_series(self, ax: plt.Axes, ax2, series: BoxSeries, idx: int): box.set_facecolor(series.color) return boxplot['boxes'][0] - # defaults markers and colors for the regular box plot - line_color = dict(color='rgb(0,0,0)') - fillcolor = series.color - marker_color = 'rgb(0,0,0)' - marker_line_color = 'rgb(0,0,0)' - marker_symbol = 'circle-open' - - # markers and colors for points only plot - if self.config_obj.box_pts: - line_color = dict(color='rgba(0,0,0,0)') - fillcolor = 'rgba(0,0,0,0)' - marker_color = series.color - marker_symbol = 'circle' - marker_line_color = series.color - - # create a trace - self.figure.add_trace( - go.Box(x=series.series_data[self.config_obj.indy_var], - y=series.series_data['stat_value'], - notched=self.config_obj.box_notch, - line=line_color, - fillcolor=fillcolor, - name=series.user_legends, - showlegend=self.config_obj.show_legend[series.idx] == 1, - # quartilemethod='linear', #"exclusive", "inclusive", or "linear" - boxmean=self.config_obj.box_avg, - boxpoints=self.config_obj.boxpoints, # outliers, all, False - pointpos=0, - marker=dict(size=4, - color=marker_color, - line=dict( - width=1, - color=marker_line_color - ), - symbol=marker_symbol, - ), - jitter=0 - ), - secondary_y=series.y_axis != 1 - ) - - self.logger.info(f"End drawing the boxes on the plot: {datetime.now()}") def _get_data_to_plot_and_x_locs(self, series, idx): base = np.arange(len(self.config_obj.indy_vals)) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 8d37863a..f091616c 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -117,11 +117,6 @@ def _create_figure(self): self.logger.info(f"Begin creating the figure: {datetime.now()}") - #self.figure = self._create_layout() - #self._add_xaxis() - #self._add_yaxis() - #self._add_legend() - annotation_text_all = '' for inx, series in enumerate(self.series_list): # Don't generate the plot for this series if @@ -151,35 +146,6 @@ def _create_figure(self): super()._create_figure() - # add custom lines - # if len(self.series_list) > 0: - # self._add_lines( - # self.config_obj, - # sorted(self.series_list[0].series_data[self.config_obj.indy_var].unique()) - # ) - - # apply y axis limits - #self._yaxis_limits() - - # add Auto-Corr Test and/or WW Runs Test results if needed - # if self.config_obj.revision_run or self.config_obj.revision_ac: - # self.figure.add_annotation(text=annotation_text_all, - # align='left', - # showarrow=False, - # xref='paper', - # yref='paper', - # x=0, - # yanchor='bottom', - # xanchor='left', - # y=1, - # font={ - # 'size': self.config_obj.legend_size, - # 'color': "black" - # }, - # bordercolor=self.config_obj.legend_border_color, - # borderwidth=0 - # ) - self.logger.info(f"Finished creating figure: {datetime.now()}") def _add_custom_lines(self, ax): @@ -190,71 +156,6 @@ def _get_data_to_plot_and_x_locs(self, series, idx): x_locs = [base[idx]] return series.series_points['points']['stat_value'].dropna().values, x_locs, MPL_DEFAULT_BOX_WIDTH - # def _draw_series(self, series: RevisionBoxSeries) -> None: - # """ - # Draws the boxes on the plot - # - # :param series: RevisionBoxSeries object with data and parameters - # """ - # - # self.logger.info(f"Begin drawing series: {datetime.now()}") - # # defaults markers and colors for the regular box plot - # line_color = dict(color='rgb(0,0,0)') - # fillcolor = series.color - # marker_color = 'rgb(0,0,0)' - # marker_line_color = 'rgb(0,0,0)' - # marker_symbol = 'circle-open' - # - # # markers and colors for points only plot - # if self.config_obj.box_pts: - # line_color = dict(color='rgba(0,0,0,0)') - # fillcolor = 'rgba(0,0,0,0)' - # marker_color = series.color - # marker_symbol = 'circle' - # marker_line_color = series.color - # - # # create a trace - # self.figure.add_trace( - # go.Box( # x=[series.idx], - # y=series.series_points['points']['stat_value'].tolist(), - # notched=self.config_obj.box_notch, - # line=line_color, - # fillcolor=fillcolor, - # name=series.user_legends, - # showlegend=self.config_obj.show_legend[series.idx] == 1, - # boxmean=self.config_obj.box_avg, - # boxpoints=self.config_obj.boxpoints, # outliers, all, False - # pointpos=0, - # marker=dict(size=4, - # color=marker_color, - # line=dict( - # width=1, - # color=marker_line_color - # ), - # symbol=marker_symbol, - # ), - # jitter=0 - # ) - # ) - # - # self.logger.info(f"Finished drawing series:{datetime.now()}") - # - # def _add_xaxis(self) -> None: - # """ - # Configures and adds x-axis to the plot - # """ - # self.figure.update_xaxes(title_text=self.config_obj.xaxis, - # linecolor=PLOTLY_AXIS_LINE_COLOR, - # linewidth=PLOTLY_AXIS_LINE_WIDTH, - # title_font={ - # 'size': self.config_obj.x_title_font_size - # }, - # title_standoff=abs(self.config_obj.parameters['xlab_offset']), - # tickangle=self.config_obj.x_tickangle, - # tickfont={'size': self.config_obj.x_tickfont_size}, - # tickmode='linear' - # ) - def write_output_file(self) -> None: """ Formats series values and dumps it into the file From 67c5a3fa3a7a387412fa7705180292230d8560c9 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Mar 2026 08:58:01 -0700 Subject: [PATCH 46/53] test copying files with differences to a diff artifact --- .github/workflows/unit_tests.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 3bad08fe..a8074f6d 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -118,7 +118,7 @@ jobs: uses: actions/checkout@v4 with: repository: dtcenter/METplus - ref: develop + ref: feature_copy_diff_files sparse-checkout: | metplus/util/diff_util.py sparse-checkout-cone-mode: false @@ -144,15 +144,23 @@ jobs: # Note: upload-artifact v4 nested paths differently, usually named by 'name' provided in upload OUTPUT_DIR="${{ runner.workspace }}/artifacts/test_output_${OUTPUT_BRANCH}_${{ matrix.python-version }}" TRUTH_DIR="${{ runner.workspace }}/artifacts/test_output_${TRUTH_BRANCH}_${{ matrix.python-version }}" + DIFF_DIR="${{ runner.workspace }}/diff_${{ matrix.python-version }}" echo "Comparing $OUTPUT_DIR and $TRUTH_DIR" # Ensure directories exist before running script if [ -d "$OUTPUT_DIR" ] && [ -d "$TRUTH_DIR" ]; then export METPLUS_DIFF_SKIP_KEYWORDS=".html,_rank.png" - python METplus/metplus/util/diff_util.py "$TRUTH_DIR" "$OUTPUT_DIR" --debug --save_diff + python METplus/metplus/util/diff_util.py "$TRUTH_DIR" "$OUTPUT_DIR" --debug --save_diff --diff_dir "$DIFF_DIR" else echo "One or both data directories are missing." ls ${{ runner.workspace }}/artifacts exit 1 fi + + - name: Upload diff artifact + uses: actions/upload-artifact@v4 + with: + name: diff_${{ matrix.python-version }} + path: ${{ runner.workspace }}/diff_${{ matrix.python-version }} + if-no-files-found: ignore From 1523642e46f6a868d5b5ca10f8e8cb60636dfc2c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:09:36 -0700 Subject: [PATCH 47/53] create diff artifact even when the step before it fails due to diffs --- .github/workflows/unit_tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index a8074f6d..30a099dc 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -159,6 +159,7 @@ jobs: fi - name: Upload diff artifact + if: always() uses: actions/upload-artifact@v4 with: name: diff_${{ matrix.python-version }} From e53688eaf5d9eee6a1aa9b17a22145fee7a0bcdb Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:51:52 -0700 Subject: [PATCH 48/53] fix issues with hovmoeller test plot by closing global matplotlib plt after creating the image so the plot content is not leftover, potentially being added to other plots. Also update the hovmoeller plot to inherit from the plotly versions of the base components because it uses plotly --- metplotpy/plots/base_plot.py | 2 ++ metplotpy/plots/hovmoeller/hovmoeller.py | 2 +- metplotpy/plots/hovmoeller/hovmoeller_config.py | 2 +- .../plots/performance_diagram/performance_diagram.py | 8 -------- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index d12efd06..edd4634b 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -295,6 +295,8 @@ def save_to_file(self) -> None: plt.savefig(image_name, dpi=self.get_config_value('plot_res')) except Exception as ex: self.logger.error(f"Failed to save plot to file: {ex}") + finally: + plt.close('all') def remove_file(self): """Removes previously made image file . diff --git a/metplotpy/plots/hovmoeller/hovmoeller.py b/metplotpy/plots/hovmoeller/hovmoeller.py index 89145be3..8770c974 100644 --- a/metplotpy/plots/hovmoeller/hovmoeller.py +++ b/metplotpy/plots/hovmoeller/hovmoeller.py @@ -36,7 +36,7 @@ """ Import BasePlot class """ -from metplotpy.plots.base_plot import BasePlot +from metplotpy.plots.base_plot_plotly import BasePlot class Hovmoeller(BasePlot): diff --git a/metplotpy/plots/hovmoeller/hovmoeller_config.py b/metplotpy/plots/hovmoeller/hovmoeller_config.py index a6b61fd4..e0e521e5 100644 --- a/metplotpy/plots/hovmoeller/hovmoeller_config.py +++ b/metplotpy/plots/hovmoeller/hovmoeller_config.py @@ -14,7 +14,7 @@ """ __author__ = 'Minna Win' -from ..config import Config +from ..config_plotly import Config class HovmoellerConfig(Config): def __init__(self, parameters): diff --git a/metplotpy/plots/performance_diagram/performance_diagram.py b/metplotpy/plots/performance_diagram/performance_diagram.py index a9d96d30..1b2bc141 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram.py +++ b/metplotpy/plots/performance_diagram/performance_diagram.py @@ -149,14 +149,6 @@ def _create_series(self, input_data): self.logger.info(f"Finished creating series objects: {datetime.now()}") return series_list - def save_to_file(self): - """ - This is the matplotlib-friendly implementation, which overrides the parent class' - version (which is a Python Plotly implementation). - - """ - plt.savefig(self.config_obj.output_image) - def remove_file(self): """ Removes previously made image file. Invoked by the parent class before self.output_file From db8b37682df71f0398a8c62bd9b2ec4b1378a8b1 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:10:25 -0700 Subject: [PATCH 49/53] Update matplotlib plots to call self.save_to_file instead of plt.savefig directly so the logic to close the plt object after the image is generated is called consistently and upon failure to write the plot. Add optional arguments in save_to_file to pass to plt.savefig to handle taylor diagram different settings based on plotting just positive or negative/positive --- metplotpy/plots/base_plot.py | 6 ++++-- .../performance_diagram.py | 4 ++-- metplotpy/plots/scatter/scatter.py | 2 +- .../plots/taylor_diagram/taylor_diagram.py | 21 +++++++------------ 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index edd4634b..75ec2ff6 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -288,11 +288,13 @@ def get_img_bytes(self): return None - def save_to_file(self) -> None: + def save_to_file(self, **kwargs) -> None: + """!Saves the plot to a file. + Add any arguments passed to the function directly to plt.savefig.""" image_name = self.get_config_value('plot_filename') os.makedirs(os.path.dirname(image_name), exist_ok=True) try: - plt.savefig(image_name, dpi=self.get_config_value('plot_res')) + plt.savefig(image_name, dpi=self.get_config_value('plot_res'), **kwargs) except Exception as ex: self.logger.error(f"Failed to save plot to file: {ex}") finally: diff --git a/metplotpy/plots/performance_diagram/performance_diagram.py b/metplotpy/plots/performance_diagram/performance_diagram.py index 1b2bc141..d61cafb2 100644 --- a/metplotpy/plots/performance_diagram/performance_diagram.py +++ b/metplotpy/plots/performance_diagram/performance_diagram.py @@ -381,10 +381,10 @@ def _create_figure(self): if self.config_obj.yaxis_2: ax2.set_ylabel(self.config_obj.yaxis_2, fontsize=9) + self.logger.info(f"Finished drawing CSI lines: {datetime.now()}") + # use plt.tight_layout() to prevent label box from scrolling off the figure plt.tight_layout() - plt.savefig(self.get_config_value('plot_filename')) - self.logger.info(f"Finished drawing CSI lines: {datetime.now()}") self.save_to_file() self.logger.info("Finished saving file.") diff --git a/metplotpy/plots/scatter/scatter.py b/metplotpy/plots/scatter/scatter.py index 3abaead7..1e36c95d 100644 --- a/metplotpy/plots/scatter/scatter.py +++ b/metplotpy/plots/scatter/scatter.py @@ -184,7 +184,7 @@ def create_figure(self, parms) -> None: # Save the plot plot_filename = self.config_obj.plot_filename self.logger.info(f"Saving scatter plot as {plot_filename}") - plt.savefig(plot_filename) + self.save_to_file() time_to_plot = datetime.now() - start self.logger.info(f"Total time for generating the scatter plot: {time_to_plot} seconds") diff --git a/metplotpy/plots/taylor_diagram/taylor_diagram.py b/metplotpy/plots/taylor_diagram/taylor_diagram.py index 77882284..146c6e9e 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram.py @@ -321,26 +321,21 @@ def _create_figure(self) -> None: frameon=self.config_obj.draw_box) plt.tight_layout() - plt.plot() + os.makedirs(os.path.dirname(self.config_obj.output_image), exist_ok=True) # Save the figure, based on whether we are displaying only positive - # correlations or all - # correlations. - os.makedirs(os.path.dirname(self.config_obj.output_image), exist_ok=True) + # correlations or all correlations. + plot_args = {} if pos_correlation_only: # Setting the bbox_inches keeps the legend box always within the plot - # boundaries. This *may* result - # in a distorted plot. - plt.savefig(self.config_obj.output_image, - dpi=self.config_obj.plot_resolution, bbox_inches="tight") - else: + # boundaries. This *may* result in a distorted plot. # setting bbox_inches causes a loss in the title, especially when there - # are numerous legend - # items. The legend inset 'y' value will likely need to + # are numerous legend items. The legend inset 'y' value will likely need to # be modified to keep all legend items on the plot. - plt.savefig(self.config_obj.output_image, - dpi=self.config_obj.plot_resolution) + plot_args['bbox_inches'] = "tight" + + self.save_to_file(**plot_args) def main(config_filename=None): From ad9f086ebb687043826ac3da5a04f5066c02f416 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:34:49 -0700 Subject: [PATCH 50/53] use develop version of diff util after changes were merged --- .github/workflows/unit_tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 30a099dc..b168d938 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -118,7 +118,7 @@ jobs: uses: actions/checkout@v4 with: repository: dtcenter/METplus - ref: feature_copy_diff_files + ref: develop sparse-checkout: | metplus/util/diff_util.py sparse-checkout-cone-mode: false From f0707c925803813722597efef82cec7371c81026 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:37:36 -0700 Subject: [PATCH 51/53] fix incorrect location of caption by overriding _add_caption function to place annotation text at top left of plot regardless of config settings --- metplotpy/plots/revision_box/revision_box.py | 33 +++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index f091616c..23756875 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -117,6 +117,20 @@ def _create_figure(self): self.logger.info(f"Begin creating the figure: {datetime.now()}") + self._create_annotation() + + # set the x-axis labels to match the user legends + self.config_obj.indy_label = self.config_obj.user_legends + + super()._create_figure() + + self.logger.info(f"Finished creating figure: {datetime.now()}") + + def _create_annotation(self): + if not self.config_obj.revision_run and not self.config_obj.revision_ac: + self.config_obj.plot_caption = None + return + annotation_text_all = '' for inx, series in enumerate(self.series_list): # Don't generate the plot for this series if @@ -124,7 +138,6 @@ def _create_figure(self): if not series.plot_disp: continue - #self._draw_series(series) # construct annotation text annotation_text = series.user_legends + ': ' if self.config_obj.revision_run: @@ -137,16 +150,20 @@ def _create_figure(self): annotation_text_all = annotation_text_all + annotation_text if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '
' + annotation_text_all = annotation_text_all + '\n' self.config_obj.plot_caption = annotation_text_all - # set the x-axis labels to match the user legends - self.config_obj.indy_label = self.config_obj.user_legends - - super()._create_figure() - - self.logger.info(f"Finished creating figure: {datetime.now()}") + def _add_caption(self, plt, font_properties): + """ + Adds a caption to the top left of the plot, just below the title. + Always uses the same position regardless of the config file settings. + """ + if self.config_obj.plot_caption: + plt.figtext(0.06, 0.90, self.config_obj.plot_caption, + fontproperties=font_properties, + color=self.config_obj.parameters['caption_col'], + ha='left') def _add_custom_lines(self, ax): return From 6086c88d0810b36dd6a140f1bd5f207a9cd45eaa Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 6 Mar 2026 14:56:11 -0700 Subject: [PATCH 52/53] clean up --- metplotpy/plots/revision_box/revision_box.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/metplotpy/plots/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 23756875..61cbfc96 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -139,18 +139,19 @@ def _create_annotation(self): continue # construct annotation text - annotation_text = series.user_legends + ': ' + annotation_text = f"{series.user_legends}: " if self.config_obj.revision_run: - annotation_text = annotation_text + 'WW Runs Test:' + series.series_points['revision_run'] + ' ' + annotation_text += f"WW Runs Test: {series.series_points['revision_run']} " if self.config_obj.revision_ac: - annotation_text = annotation_text + "Auto-Corr Test: p=" \ - + series.series_points['auto_cor_p'] \ - + ", r=" + series.series_points['auto_cor_r'] + annotation_text += ( + f"Auto-Corr Test: p={series.series_points['auto_cor_p']}, " + f"r={series.series_points['auto_cor_r']}" + ) - annotation_text_all = annotation_text_all + annotation_text + annotation_text_all += annotation_text if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '\n' + annotation_text_all += '\n' self.config_obj.plot_caption = annotation_text_all From 6b5ac4593f51d78648b3c8fdbefb3192db98e679 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:51:06 -0600 Subject: [PATCH 53/53] Apply suggestion from @bikegeek Co-authored-by: MWin <3753118+bikegeek@users.noreply.github.com> --- metplotpy/plots/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index 1f8cf0cd..5c0a41ee 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -99,7 +99,7 @@ def make_plot(config_filename, plot_class): try: plot = plot_class(params) plot.save_to_file() - # 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()}")