diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 3bad08fe..b168d938 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -144,15 +144,24 @@ 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 + if: always() + uses: actions/upload-artifact@v4 + with: + name: diff_${{ matrix.python-version }} + path: ${{ runner.workspace }}/diff_${{ matrix.python-version }} + if-no-files-found: ignore diff --git a/metplotpy/plots/bar/bar.py b/metplotpy/plots/bar/bar.py index 771ffbf4..a90da4c8 100644 --- a/metplotpy/plots/bar/bar.py +++ b/metplotpy/plots/bar/bar.py @@ -17,18 +17,17 @@ 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 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): @@ -55,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()}") @@ -159,6 +147,9 @@ 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): @@ -166,43 +157,45 @@ def _create_figure(self): Create a bar plot from defaults and custom parameters """ # 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) + # 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): # 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()) - ) - - # apply y axis limits - self._yaxis_limits() + return n_stats - # 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 +203,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 +212,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,244 +228,16 @@ 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, - } - ) - - return fig + 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 = constants.MPL_DEFAULT_BAR_WIDTH / n + offset = (idx - (n - 1) / 2.0) * width + x_locs = base + offset - 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}, - type='category' - ) - # reverse xaxis if needed - if 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} - ) - - 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" - } - }) - - 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}) - - 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 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" - - # save html - self.figure.write_html(html_name, include_plotlyjs=False) + # 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 write_output_file(self) -> None: """ diff --git a/metplotpy/plots/bar/bar_config.py b/metplotpy/plots/bar/bar_config.py index 091d1214..cd012ec8 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 @@ -53,12 +52,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 +75,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 @@ -141,7 +139,6 @@ def _get_plot_disp(self) -> list: 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 @@ -149,7 +146,6 @@ def _get_fcst_vars(self, index): 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') @@ -183,38 +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) - - """ - # 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. @@ -267,8 +231,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_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) @@ -301,12 +265,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: - 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) + 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 # in the lists to produce all permutations of the series_val values and the diff --git a/metplotpy/plots/base_plot.py b/metplotpy/plots/base_plot.py index 7d86bfd3..75ec2ff6 100644 --- a/metplotpy/plots/base_plot.py +++ b/metplotpy/plots/base_plot.py @@ -18,10 +18,15 @@ 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 from .config import Config +from . import constants + turn_on_logging = strtobool('LOG_BASE_PLOT') # Log when Chrome is downloaded at runtime @@ -183,7 +188,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,8 +204,32 @@ def get_weights_size_styles(self): ylab_property.set_weight(ylab_wt) weights_size_styles['ylab'] = ylab_property - return weights_size_styles + # For x2axis label if set + 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 + x2lab_property.set_style(x2lab_style) + 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 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 + y2lab_property.set_style(y2lab_style) + y2lab_property.set_weight(y2lab_wt) + weights_size_styles['y2lab'] = y2lab_property + return weights_size_styles def get_config_value(self, *args): @@ -260,39 +288,17 @@ 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, **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') - - # 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'), **kwargs) + 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 . @@ -303,28 +309,27 @@ def remove_file(self): if image_name is not None and os.path.exists(image_name): 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) + ax.axvline(x=x, ymin=0, ymax=1, **line_properties) @staticmethod def get_array_dimensions(data): @@ -340,3 +345,177 @@ 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'], + ) + + def _add_legend(self, ax: plt.Axes, handles_and_labels=None) -> None: + """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" + + 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: + 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. + + 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 + """ + 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 + + 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 4747f141..fb359f25 100644 --- a/metplotpy/plots/box/box.py +++ b/metplotpy/plots/box/box.py @@ -21,18 +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.constants import MPL_DEFAULT_BOX_WIDTH class Box(BasePlot): @@ -61,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() @@ -86,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): @@ -105,11 +91,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): @@ -128,8 +112,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,122 +155,145 @@ 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 = None + if wts_size_styles.get('y2lab'): + 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) - # 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()) - ) + self._add_xaxis(ax, wts_size_styles['xlab']) + self._add_yaxis(ax, wts_size_styles['ylab']) - # apply y axis limits - self._yaxis_limits() - self._y2axis_limits() + # add x2 axis + if wts_size_styles.get('x2lab'): + 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._add_custom_lines(ax) - self.figure.update_layout(boxmode='group') + plt.tight_layout() - 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 _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 + + # 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()}") - # 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: " - f"{datetime.now()}") - - @staticmethod - def _find_min_max(series: BoxSeries, yaxis_min: Union[float, None], + self.logger.info(f"Begin drawing the boxes on the plot for {series.series_name}: {datetime.now()}") + + # 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) + + plot_ax = ax + if ax2 and ax2.get_ylabel() in series.series_data.stat_name.values: + plot_ax = ax2 + + # 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) + + return boxplot['boxes'][0] + + 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) + 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())) + + # aggregate number of stats + # 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 + + 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 +304,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 +315,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 +330,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..dc788ceb 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,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 ############################################## @@ -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 @@ -87,15 +93,20 @@ 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 - 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 @@ -126,17 +137,24 @@ 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: + 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] + self.showfliers = False + self.boxpoints = False def _get_plot_disp(self) -> list: """ @@ -209,38 +227,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. @@ -314,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: @@ -353,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() @@ -369,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() @@ -382,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/box/box_series.py b/metplotpy/plots/box/box_series.py index a0434f3a..0778a2a9 100644 --- a/metplotpy/plots/box/box_series.py +++ b/metplotpy/plots/box/box_series.py @@ -22,7 +22,9 @@ 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 +267,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()}") diff --git a/metplotpy/plots/config.py b/metplotpy/plots/config.py index a1dec79f..fc3a8f08 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 @@ -45,8 +46,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') @@ -59,7 +62,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') @@ -145,8 +148,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(): @@ -179,6 +184,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 @@ -227,11 +235,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 @@ -404,6 +409,43 @@ 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 {} + + 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. + + 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_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: """ Get a list of all the variable value names (i.e. inner key of the @@ -833,7 +875,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 @@ -869,4 +911,60 @@ 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 + + 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/constants.py b/metplotpy/plots/constants.py index 8e822b53..5b91b679 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"] @@ -90,3 +92,6 @@ # Matplotlib constants MPL_FONT_SIZE_DEFAULT = 11 + +MPL_DEFAULT_BAR_WIDTH = 0.8 +MPL_DEFAULT_BOX_WIDTH = 0.5 diff --git a/metplotpy/plots/histogram/hist.py b/metplotpy/plots/histogram/hist.py index 0474f1d2..8e4e9e88 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 @@ -69,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() @@ -256,199 +242,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._add_legend(ax) + plt.tight_layout() - self.logger.info(f"Finished creating the histogram figure: " - f"{datetime.now()}") + self.logger.info(f"Finished creating the histogram figure: {datetime.now()}") - def _draw_series(self, series: HistSeries) -> None: + 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] - ) - ) - - 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 + 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], ) - 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 +324,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..932fe4ee 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 @@ -128,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/histogram/hist_series.py b/metplotpy/plots/histogram/hist_series.py index 1e30222d..d404a3f6 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 @@ -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 @@ -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 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..d61cafb2 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 @@ -389,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/revision_box/revision_box.py b/metplotpy/plots/revision_box/revision_box.py index 35aa020b..61cbfc96 100644 --- a/metplotpy/plots/revision_box/revision_box.py +++ b/metplotpy/plots/revision_box/revision_box.py @@ -9,21 +9,23 @@ """ 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 +import numpy as np + +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): @@ -51,14 +53,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() @@ -107,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) @@ -121,126 +116,63 @@ 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._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 # 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'] + # construct annotation text + annotation_text = f"{series.user_legends}: " + if self.config_obj.revision_run: + annotation_text += f"WW Runs Test: {series.series_points['revision_run']} " - annotation_text_all = annotation_text_all + annotation_text - if inx < len(self.series_list) - 1: - annotation_text_all = annotation_text_all + '
' - - # 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 _draw_series(self, series: RevisionBoxSeries) -> None: - """ - Draws the boxes on the plot + if self.config_obj.revision_ac: + annotation_text += ( + f"Auto-Corr Test: p={series.series_points['auto_cor_p']}, " + f"r={series.series_points['auto_cor_r']}" + ) - :param series: RevisionBoxSeries object with data and parameters - """ + annotation_text_all += annotation_text + if inx < len(self.series_list) - 1: + annotation_text_all += '\n' + + self.config_obj.plot_caption = annotation_text_all - 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: + def _add_caption(self, plt, font_properties): """ - Configures and adds x-axis to the plot + 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. """ - 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.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 + + def _get_data_to_plot_and_x_locs(self, series, idx): + 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 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..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_plotly import Config -from .. import constants_plotly as constants -from .. import util_plotly as util +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,173 +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') + # 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 - # 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 - self.xaxis = util.apply_weight_style(self.xaxis, self.parameters['xlab_weight']) - - ############################################## - # 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' + 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. @@ -218,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))) @@ -227,35 +75,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 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: 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 f029da97..146c6e9e 100644 --- a/metplotpy/plots/taylor_diagram/taylor_diagram.py +++ b/metplotpy/plots/taylor_diagram/taylor_diagram.py @@ -292,11 +292,12 @@ def _create_figure(self) -> None: ) # 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=wts_size_styles['caption'], color=self.config_obj.caption_color + fontproperties=caption, color=self.config_obj.caption_color ) # Add a figure legend @@ -320,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): diff --git a/metplotpy/plots/util.py b/metplotpy/plots/util.py index 82011cbc..5c0a41ee 100644 --- a/metplotpy/plots/util.py +++ b/metplotpy/plots/util.py @@ -86,6 +86,29 @@ def get_params(config_filename): return parse_config(config_file) +def make_plot(config_filename, plot_class): + """!Get plot parameters and create the plot. + + @param config_filename The full path to the config or None + @param plot_class class of plot to produce, e.g. Bar or Box + @returns plot class object or None if something went wrong + """ + # Retrieve the contents of the custom config file to over-ride + # or augment settings defined by the default config file. + params = get_params(config_filename) + try: + plot = plot_class(params) + plot.save_to_file() + + 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. 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..65b03581 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 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 diff --git a/test/box/custom_box.yaml b/test/box/custom_box.yaml index a3749b9b..8f034170 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 @@ -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: @@ -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 diff --git a/test/histogram/prob_hist.yaml b/test/histogram/prob_hist.yaml index f4610ca0..40ae551d 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: [] @@ -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