From 2a7b085fb4286ef3f7ec3d7176a0805944b097c3 Mon Sep 17 00:00:00 2001 From: bikegeek <3753118+bikegeek@users.noreply.github.com> Date: Wed, 27 Oct 2021 15:48:05 -0600 Subject: [PATCH 01/43] Feature 1091 extent cycloneplotter (#1218) * Github #1091 add support to define plot's extent * Github #1091 Add support for defining plot's extent * Github #1091 add support for defining plot extent * GitHub #1091 Provide support to define plotting a bounding box of interest and replace deprecated cartopy attributes. * Github Issue #1091 Add configuration support to define the bounding box defining the area of interest to plot. * Github Issue #1091 provide support to define bounding box defining the area of interest to plot. * Github Issue #1091 Removed unused shapely imports * GItHub Issue #1091 Removed duplicate CYCLONE_PLOTTER_GLOBAL_PLOT * Github #1091 Include config settings needed to indicate the region of interest to be plotted. * Github Issue #1091 Keep only the North Hemisphere lon and lats in the config file to serve as example for plotting a specific area of the map. --- metplus/wrappers/cyclone_plotter_wrapper.py | 1228 +++++++++-------- .../CyclonePlotter/CyclonePlotter.conf | 31 + ...ter_fcstGFS_obsGFS_UserScript_ExtraTC.conf | 25 +- .../Plotter_fcstGFS_obsGFS_ExtraTC.conf | 22 +- 4 files changed, 742 insertions(+), 564 deletions(-) diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index 2497ff654f..5c177f6fc9 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -1,562 +1,668 @@ -"""!@namespace ExtraTropicalCyclonePlotter -A Python class that generates plots of extra tropical cyclone forecast data, - replicating the NCEP tropical and extra tropical cyclone tracks and - verification plots http://www.emc.ncep.noaa.gov/mmb/gplou/emchurr/glblgen/ -""" - -import os -import time -import datetime -import re -import sys -from collections import namedtuple - - -# handle if module can't be loaded to run wrapper -WRAPPER_CANNOT_RUN = False -EXCEPTION_ERR = '' -try: - import pandas as pd - import matplotlib.pyplot as plt - import matplotlib.ticker as mticker - import cartopy.crs as ccrs - import cartopy.feature as cfeature - import cartopy - from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER - - ##If the script is run on a limited-internet access machine, the CARTOPY_DIR environment setting - ##will need to be set in the user-specific system configuration file. Review the Installation section - ##of the User's Guide for more details. - if os.getenv('CARTOPY_DIR'): - cartopy.config['data_dir'] = os.getenv('CARTOPY_DIR', cartopy.config.get('data_dir')) - -except Exception as err_msg: - WRAPPER_CANNOT_RUN = True - EXCEPTION_ERR = err_msg - -import produtil.setup - -from ..util import met_util as util -from ..util import do_string_sub -from . import CommandBuilder - - -class CyclonePlotterWrapper(CommandBuilder): - """! Generate plots of extra tropical storm forecast tracks. - Reads input from ATCF files generated from MET TC-Pairs - """ - - def __init__(self, config, instance=None, config_overrides=None): - self.app_name = 'cyclone_plotter' - - super().__init__(config, - instance=instance, - config_overrides=config_overrides) - - if WRAPPER_CANNOT_RUN: - self.log_error("There was a problem importing modules: " - f"{EXCEPTION_ERR}\n") - return - - self.input_data = self.config.getdir('CYCLONE_PLOTTER_INPUT_DIR') - self.output_dir = self.config.getdir('CYCLONE_PLOTTER_OUTPUT_DIR') - self.init_date = self.config.getraw('config', - 'CYCLONE_PLOTTER_INIT_DATE') - self.init_hr = self.config.getraw('config', 'CYCLONE_PLOTTER_INIT_HR') - - init_time_fmt = self.config.getstr('config', 'INIT_TIME_FMT', '') - - if init_time_fmt: - clock_time = datetime.datetime.strptime( - self.config.getstr('config', - 'CLOCK_TIME'), - '%Y%m%d%H%M%S' - ) - - init_beg = self.config.getraw('config', 'INIT_BEG') - if init_beg: - init_beg_dt = util.get_time_obj(init_beg, - init_time_fmt, - clock_time, - logger=self.logger) - self.init_date = do_string_sub(self.init_date, init=init_beg_dt) - self.init_hr = do_string_sub(self.init_hr, init=init_beg_dt) - - self.model = self.config.getstr('config', 'CYCLONE_PLOTTER_MODEL') - self.title = self.config.getstr('config', - 'CYCLONE_PLOTTER_PLOT_TITLE') - self.gen_ascii = ( - self.config.getbool('config', - 'CYCLONE_PLOTTER_GENERATE_TRACK_ASCII') - ) - # Create a set to keep track of unique storm_ids for each track file. - self.unique_storm_id = set() - # Data structure to separate data based on storm id. - self.storm_id_dict = {} - - # Data/info which we want to retrieve from the track files. - self.columns_of_interest = ['AMODEL', 'STORM_ID', 'INIT', - 'LEAD', 'VALID', 'ALAT', 'ALON'] - self.circle_marker_size = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE') - ) - self.annotation_font_size = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_ANNOTATION_FONT_SIZE') - ) - - self.legend_font_size = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_LEGEND_FONT_SIZE') - ) - - # Map centered on Pacific Ocean - self.central_latitude = 180.0 - - self.cross_marker_size = (self.config.getint('config', - 'CYCLONE_PLOTTER_CROSS_MARKER_SIZE') - ) - self.resolution_dpi = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_RESOLUTION_DPI') - ) - - self.add_watermark = self.config.getbool('config', - 'CYCLONE_PLOTTER_ADD_WATERMARK', - True) - # Set the marker symbols, '+' for a small cross, '.' for a small circle. - # NOTE: originally, 'o' was used. 'o' is also a circle, but it creates a - # very large filled circle on the plots that appears too large. - self.cross_marker = '+' - self.circle_marker = '.' - - - def run_all_times(self): - """! Calls the defs needed to create the cyclone plots - run_all_times() is required by CommandBuilder. - - """ - self.sanitized_df = self.retrieve_data() - self.create_plot() - - - def retrieve_data(self): - """! Retrieve data from track files. - Returns: - sanitized_df: a pandas dataframe containing the - "sanitized" longitudes, as well as some markers and - lead group information needed for generating - scatter plots. - - """ - self.logger.debug("Begin retrieving data...") - all_tracks_list = [] - - # Store the data in the track list. - if os.path.isdir(self.input_data): - self.logger.debug("Get data from all files in the directory " + - self.input_data) - # Get the list of all files (full file path) in this directory - all_input_files = util.get_files(self.input_data, ".*.tcst", - self.logger) - - # read each file into pandas then concatenate them together - df_list = [pd.read_csv(file, delim_whitespace=True) for file in all_input_files] - combined = pd.concat(df_list, ignore_index=True) - - # check for empty dataframe, set error message and exit - if combined.empty: - self.logger.error("No data found in specified files. Please check your config file settings.") - sys.exit("No data found.") - - # if there are any NaN values in the ALAT, ALON, STORM_ID, LEAD, INIT, AMODEL, or VALID column, - # drop that row of data (axis=0). We need all these columns to contain valid data in order - # to create a meaningful plot. - combined_df = combined.copy(deep=True) - combined_df = combined.dropna(axis=0, how='any', - subset=self.columns_of_interest) - - # Retrieve and create the columns of interest - self.logger.debug(f"Number of rows of data: {combined_df.shape[0]}") - combined_subset = combined_df[self.columns_of_interest] - df = combined_subset.copy(deep=True) - df.allows_duplicate_labels = False - # INIT, LEAD, VALID correspond to the column headers from the MET - # TC tool output. INIT_YMD, INIT_HOUR, VALID_DD, and VALID_HOUR are - # new columns (for a new dataframe) created from these MET columns. - df['INIT'] = df['INIT'].astype(str) - df['INIT_YMD'] = (df['INIT'].str[:8]).astype(int) - df['INIT_HOUR'] = (df['INIT'].str[9:11]).astype(int) - df['LEAD'] = df['LEAD']/10000 - df['LEAD'] = df['LEAD'].astype(int) - df['VALID_DD'] = (df['VALID'].str[6:8]).astype(int) - df['VALID_HOUR'] = (df['VALID'].str[9:11]).astype(int) - df['VALID'] = df['VALID'].astype(int) - - # Subset the dataframe to include only the data relevant to the user's criteria as - # specified in the configuration file. - init_date = int(self.init_date) - init_hh = int(self.init_hr) - model_name = self.model - - if model_name: - self.logger.debug("Subsetting based on " + str(init_date) + " " + str(init_hh) + - ", and model:" + model_name ) - mask = df[(df['AMODEL'] == model_name) & (df['INIT_YMD'] >= init_date) & - (df['INIT_HOUR'] >= init_hh)] - else: - # no model specified, just subset on init date and init hour - mask = df[(df['INIT_YMD'] >= init_date) & - (df['INIT_HOUR'] >= init_hh)] - self.logger.debug("Subsetting based on " + str(init_date) + ", and "+ str(init_hh)) - - user_criteria_df = mask - # reset the index so things are ordered properly in the new dataframe - user_criteria_df.reset_index(inplace=True) - - # Aggregate the ALON values based on unique storm id to facilitate "sanitizing" the - # longitude values (to handle lons that cross the International Date Line). - unique_storm_ids_set = set(user_criteria_df['STORM_ID']) - self.unique_storm_ids = list(unique_storm_ids_set) - nunique = len(self.unique_storm_ids) - self.logger.debug(f" {nunique} unique storm ids identified") - - # Use named tuples to store the relevant storm track information (their index value in the dataframe, - # track id, and ALON values and later on the SLON (sanitized ALON values). - TrackPt = namedtuple("TrackPt", "indices track alons alats") - - # named tuple holding "sanitized" longitudes - SanTrackPt = namedtuple("SanTrackPt", "indices track alons slons") - - # Keep track of the unique storm tracks by their storm_id - storm_track_dict = {} - - for cur_unique in self.unique_storm_ids: - idx_list = user_criteria_df.index[user_criteria_df['STORM_ID'] == cur_unique].tolist() - alons = [] - alats = [] - indices = [] - - for idx in idx_list: - alons.append(user_criteria_df.loc[idx, 'ALON']) - alats.append(user_criteria_df.loc[idx, 'ALAT']) - indices.append(idx) - - # create the track_pt tuple and add it to the storm track dictionary - track_pt = TrackPt(indices, cur_unique, alons, alats) - storm_track_dict[cur_unique] = track_pt - - # create a new dataframe to contain the sanitized lons (i.e. the original ALONs that have - # been cleaned up when crossing the International Date Line) - sanitized_df = user_criteria_df.copy(deep=True) - - # Now we have a dictionary that helps in aggregating the data based on - # storm tracks (via storm id) and will contain the "sanitized" lons - sanitized_storm_tracks = {} - for key in storm_track_dict: - # "Sanitize" the longitudes to shift the lons that cross the International Date Line. - # Create a new SanTrackPt named tuple and add that to a new dictionary - # that keeps track of the sanitized data based on the storm id - # sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) - sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) - sanitized_track_pt = SanTrackPt(storm_track_dict[key].indices, storm_track_dict[key].track, - storm_track_dict[key].alons, sanitized_lons) - sanitized_storm_tracks[key] = sanitized_track_pt - - # fill in the sanitized dataframe, sanitized_df - for key in sanitized_storm_tracks: - # now use the indices of the storm tracks to correctly assign the sanitized - # lons to the appropriate row in the dataframe to maintain the row ordering of - # the original dataframe - idx_list = sanitized_storm_tracks[key].indices - - for i, idx in enumerate(idx_list): - sanitized_df.loc[idx,'SLON'] = sanitized_storm_tracks[key].slons[i] - - # Set some useful values used for plotting. - # Set the IS_FIRST value to True if this is the first - # point in the storm track, False - # otherwise - if i == 0: - sanitized_df.loc[idx, 'IS_FIRST'] = True - else: - sanitized_df.loc[idx, 'IS_FIRST'] = False - - # Set the lead group to the character '0' if the valid hour is 0 or 12, - # or to the charcter '6' if the valid hour is 6 or 18. Set the marker - # to correspond to the valid hour: 'o' (open circle) for 0 or 12 valid hour, - # or '+' (small plus/cross) for 6 or 18. - if sanitized_df.loc[idx, 'VALID_HOUR'] == 0 or sanitized_df.loc[idx, 'VALID_HOUR'] == 12: - sanitized_df.loc[idx, 'LEAD_GROUP'] ='0' - sanitized_df.loc[idx, 'MARKER'] = self.circle_marker - elif sanitized_df.loc[idx, 'VALID_HOUR'] == 6 or sanitized_df.loc[idx, 'VALID_HOUR'] == 18: - sanitized_df.loc[idx, 'LEAD_GROUP'] = '6' - sanitized_df.loc[idx, 'MARKER'] = self.cross_marker - - # Write output ASCII file (csv) summarizing the information extracted from the input - # which is used to generate the plot. - if self.gen_ascii: - self.logger.debug(f" output dir: {self.output_dir}") - util.mkdir_p(self.output_dir) - ascii_track_parts = [self.init_date, '.csv'] - ascii_track_output_name = ''.join(ascii_track_parts) - final_df_filename = os.path.join(self.output_dir, ascii_track_output_name) - - # Make sure that the dataframe is sorted by STORM_ID, INIT_YMD, INIT_HOUR, and LEAD - # to ensure that the line plot is connecting the points in the correct order. - # sanitized_df.sort_values(by=['STORM_ID', 'INIT_YMD', 'INIT_HOUR', 'LEAD'], - # inplace=True).reset_index(drop=True,inplace=True) - # sanitized_df.reset_index(inplace=True,drop=True) - final_df = sanitized_df.sort_values(by=['STORM_ID', 'INIT_YMD', 'INIT_HOUR', 'LEAD'], ignore_index=True) - final_df.to_csv(final_df_filename) - else: - # The user's specified directory isn't valid, log the error and exit. - self.logger.error("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory, check config file.") - sys.exit("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory.") - return final_df - - - def create_plot(self): - """ - Create the plot, using Cartopy - - """ - - # Use PlateCarree projection for scatter plots - # and Geodetic projection for line plots. - cm_lon = self.central_latitude - ax = plt.axes(projection=ccrs.PlateCarree(central_longitude=cm_lon)) - prj = ccrs.PlateCarree() - # for transforming the annotations (matplotlib to cartopy workaround from Stack Overflow) - transform = ccrs.PlateCarree()._as_mpl_transform(ax) - - # Add land, coastlines, and ocean - ax.add_feature(cfeature.LAND) - ax.coastlines() - ax.add_feature(cfeature.OCEAN) - - # keep map zoomed out to full world. If this - # is absent, the default behavior is to zoom - # into the portion of the map that contains points. - ax.set_global() - - # Add grid lines for longitude and latitude - gl = ax.gridlines(crs=prj, - draw_labels=True, linewidth=1, color='gray', - alpha=0.5, linestyle='--') - gl.xlabels_top = False - gl.ylabels_left = False - gl.xlines = True - gl.xformatter = LONGITUDE_FORMATTER - gl.yformatter = LATITUDE_FORMATTER - gl.xlabel_style = {'size': 9, 'color': 'blue'} - gl.xlabel_style = {'color': 'black', 'weight': 'normal'} - - # Plot title - plt.title(self.title + "\nFor forecast with initial time = " + - self.init_date) - - # Optional: Create the NCAR watermark with a timestamp - # This will appear in the bottom right corner of the plot, below - # the x-axis. NOTE: The timestamp is in the user's local time zone - # and not in UTC time. - if self.add_watermark: - ts = time.time() - st = datetime.datetime.fromtimestamp(ts).strftime( - '%Y-%m-%d %H:%M:%S') - watermark = 'DTC METplus\nplot created at: ' + st - plt.text(60, -130, watermark, fontsize=5, alpha=0.25) - - # Make sure the output directory exists, and create it if it doesn't. - util.mkdir_p(self.output_dir) - - # get the points for the scatter plots (and the relevant information for annotations, etc.) - points_list = self.get_plot_points() - - # Legend labels - lead_group_0_legend = "Indicates a position at 00 or 12 UTC" - lead_group_6_legend = "Indicates a position at 06 or 18 UTC" - - # to be consistent with the NOAA website, use red for annotations, markers, and lines. - pt_color = 'red' - cross_marker_size = self.cross_marker_size - circle_marker_size = self.circle_marker_size - - # Get all the lat and lon (i.e. x and y) points for the '+' and 'o' marker types - # to be used in generating the scatter plots (one for the 0/12 hr and one for the 6/18 hr lead - # groups). Also collect ALL the lons and lats, which will be used to generate the - # line plot (the last plot that goes on top of all the scatter plots). - cross_lons = [] - cross_lats = [] - cross_annotations = [] - circle_lons = [] - circle_lats = [] - circle_annotations = [] - - for idx,pt in enumerate(points_list): - if pt.marker == self.cross_marker: - cross_lons.append(pt.lon) - cross_lats.append(pt.lat) - cross_annotations.append(pt.annotation) - # cross_marker = pt.marker - elif pt.marker == self.circle_marker: - circle_lons.append(pt.lon) - circle_lats.append(pt.lat) - circle_annotations.append(pt.annotation) - # circle_marker = pt.marker - - # Now generate the scatter plots for the lead group 0/12 hr ('+' marker) and the - # lead group 6/18 hr ('.' marker). - plt.scatter(circle_lons, circle_lats, s=self.circle_marker_size, c=pt_color, - marker=self.circle_marker, zorder=2, label=lead_group_0_legend, transform=prj ) - plt.scatter(cross_lons, cross_lats, s=self.cross_marker_size, c=pt_color, - marker=self.cross_marker, zorder=2, label=lead_group_6_legend, transform=prj) - - # annotations for the scatter plots - counter = 0 - for x,y in zip(circle_lons, circle_lats): - plt.annotate(circle_annotations[counter], (x,y+1), xycoords=transform, color=pt_color, - fontsize=self.annotation_font_size) - counter += 1 - - counter = 0 - for x, y in zip(cross_lons, cross_lats): - plt.annotate(cross_annotations[counter], (x, y + 1), xycoords=transform, color=pt_color, - fontsize=self.annotation_font_size) - counter += 1 - - # Dummy point to add the additional label explaining the labelling of the first - # point in the storm track - plt.scatter(0, 0, zorder=2, marker=None, c='', - label="Date (dd/hhz) is the first " + - "time storm was able to be tracked in model") - - # Settings for the legend box location. - ax.legend(loc='lower left', bbox_to_anchor=(0, -0.4), - fancybox=True, shadow=True, scatterpoints=1, - prop={'size':self.legend_font_size}) - - # Generate the line plot - # First collect all the lats and lons for each storm track. Then for each storm track, - # generate a line plot. - pts_by_track_dict = self.get_points_by_track() - - for key in pts_by_track_dict: - lons = [] - lats = [] - for idx, pt in enumerate(pts_by_track_dict[key]): - lons.append(pt.lon) - lats.append(pt.lat) - - # Create the line plot for the current storm track, use the Geodetic coordinate reference system - # to correctly connect adjacent points that have been sanitized and cross the - # International Date line or the Prime Meridian. - plt.plot(lons, lats, linestyle='-', color=pt_color, linewidth=.4, transform=ccrs.Geodetic(), zorder=3) - - # Write the plot to the output directory - out_filename_parts = [self.init_date, '.png'] - output_plot_name = ''.join(out_filename_parts) - plot_filename = os.path.join(self.output_dir, output_plot_name) - if self.resolution_dpi > 0: - plt.savefig(plot_filename, dpi=self.resolution_dpi) - else: - # use Matplotlib's default if no resolution is set in config file - plt.savefig(plot_filename) - - - def get_plot_points(self): - """ - Get the lon and lat points to be plotted, along with any other plotting-relevant - information like the marker, whether this is a first point (to be used in - annotating the first point using the valid day date and valid hour), etc. - - :return: A list of named tuples that represent the points to plot with corresponding - plotting information - """ - - # Create a named tuple to store the point information - PlotPt = namedtuple("PlotPt", "storm_id lon lat is_first marker valid_dd valid_hour annotation") - - points_list = [] - storm_id = self.sanitized_df['STORM_ID'] - lons = self.sanitized_df['SLON'] - lats = self.sanitized_df['ALAT'] - is_first_list = self.sanitized_df['IS_FIRST'] - marker_list = self.sanitized_df['MARKER'] - valid_dd_list = self.sanitized_df['VALID_DD'] - valid_hour_list = self.sanitized_df['VALID_HOUR'] - annotation_list = [] - - for idx, cur_lon in enumerate(lons): - if is_first_list[idx] is True: - annotation = str(valid_dd_list[idx]).zfill(2) + '/' + \ - str(valid_hour_list[idx]).zfill(2) + 'z' - else: - annotation = None - - annotation_list.append(annotation) - cur_pt = PlotPt(storm_id, lons[idx], lats[idx], is_first_list[idx], marker_list[idx], - valid_dd_list[idx], valid_hour_list[idx], annotation) - points_list.append(cur_pt) - - return points_list - - - def get_points_by_track(self): - """ - Get all the lats and lons for each storm track. Used to generate the line - plot of the storm tracks. - - Args: - - :return: - points_by_track: Points aggregated by storm track. - Returns a dictionary where the key is the storm_id - and values are the points (lon,lat) stored in a named tuple - """ - track_dict = {} - LonLat = namedtuple("LonLat", "lon lat") - for cur_unique in self.unique_storm_ids: - # retrieve the ALAT and ALON values that correspond to the rows for a unique storm id. - # i.e. Get the index value(s) corresponding to this unique storm id - idx_list = self.sanitized_df.index[self.sanitized_df['STORM_ID'] == cur_unique].tolist() - sanitized_lons_and_lats = [] - indices = [] - for idx in idx_list: - cur_lonlat = LonLat(self.sanitized_df.loc[idx, 'SLON'], self.sanitized_df.loc[idx, 'ALAT']) - sanitized_lons_and_lats.append(cur_lonlat) - indices.append(idx) - - # update the track dictionary - track_dict[cur_unique] = sanitized_lons_and_lats - - return track_dict - - - @staticmethod - def sanitize_lonlist(lon): - """ - Solution from Stack Overflow for "sanitizing" longitudes that cross the International Date Line - https://stackoverflow.com/questions/67730660/plotting-line-across-international-dateline-with-cartopy - - Args: - @param lon: A list of longitudes (float) that correspond to a storm track - - Returns: - new_list: a list of "sanitized" lons that are "corrected" for crossing the - International Date Line - """ - - new_list = [] - oldval = 0 - # used to compare adjacent longitudes in a storm track - treshold = 10 - for ix, ea in enumerate(lon): - diff = oldval - ea - if (ix > 0): - if (diff > treshold): - ea = ea + 360 - oldval = ea - new_list.append(ea) + +"""!@namespace ExtraTropicalCyclonePlotter +A Python class that generates plots of extra tropical cyclone forecast data, + replicating the NCEP tropical and extra tropical cyclone tracks and + verification plots http://www.emc.ncep.noaa.gov/mmb/gplou/emchurr/glblgen/ +""" + +import os +import time +import datetime +import re +import sys +from collections import namedtuple + + +# handle if module can't be loaded to run wrapper +WRAPPER_CANNOT_RUN = False +EXCEPTION_ERR = '' +try: + import pandas as pd + import matplotlib.pyplot as plt + import matplotlib.ticker as mticker + import cartopy.crs as ccrs + import cartopy.feature as cfeature + import cartopy + from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER + + ##If the script is run on a limited-internet access machine, the CARTOPY_DIR environment setting + ##will need to be set in the user-specific system configuration file. Review the Installation section + ##of the User's Guide for more details. + if os.getenv('CARTOPY_DIR'): + cartopy.config['data_dir'] = os.getenv('CARTOPY_DIR', cartopy.config.get('data_dir')) + +except Exception as err_msg: + WRAPPER_CANNOT_RUN = True + EXCEPTION_ERR = err_msg + +import produtil.setup + +from ..util import met_util as util +from ..util import do_string_sub +from . import CommandBuilder + + +class CyclonePlotterWrapper(CommandBuilder): + """! Generate plots of extra tropical storm forecast tracks. + Reads input from ATCF files generated from MET TC-Pairs + """ + + def __init__(self, config, instance=None, config_overrides={}): + self.app_name = 'cyclone_plotter' + + super().__init__(config, + instance=instance, + config_overrides=config_overrides) + + if WRAPPER_CANNOT_RUN: + self.log_error("There was a problem importing modules: " + f"{EXCEPTION_ERR}\n") + return + + self.input_data = self.config.getdir('CYCLONE_PLOTTER_INPUT_DIR') + self.output_dir = self.config.getdir('CYCLONE_PLOTTER_OUTPUT_DIR') + self.init_date = self.config.getraw('config', + 'CYCLONE_PLOTTER_INIT_DATE') + self.init_hr = self.config.getraw('config', 'CYCLONE_PLOTTER_INIT_HR') + + init_time_fmt = self.config.getstr('config', 'INIT_TIME_FMT', '') + + if init_time_fmt: + clock_time = datetime.datetime.strptime( + self.config.getstr('config', + 'CLOCK_TIME'), + '%Y%m%d%H%M%S' + ) + + init_beg = self.config.getraw('config', 'INIT_BEG') + if init_beg: + init_beg_dt = util.get_time_obj(init_beg, + init_time_fmt, + clock_time, + logger=self.logger) + self.init_date = do_string_sub(self.init_date, init=init_beg_dt) + self.init_hr = do_string_sub(self.init_hr, init=init_beg_dt) + + self.model = self.config.getstr('config', 'CYCLONE_PLOTTER_MODEL') + self.title = self.config.getstr('config', + 'CYCLONE_PLOTTER_PLOT_TITLE') + self.gen_ascii = ( + self.config.getbool('config', + 'CYCLONE_PLOTTER_GENERATE_TRACK_ASCII') + ) + # Create a set to keep track of unique storm_ids for each track file. + self.unique_storm_id = set() + # Data structure to separate data based on storm id. + self.storm_id_dict = {} + + # Data/info which we want to retrieve from the track files. + self.columns_of_interest = ['AMODEL', 'STORM_ID', 'INIT', + 'LEAD', 'VALID', 'ALAT', 'ALON'] + self.circle_marker_size = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE') + ) + self.annotation_font_size = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_ANNOTATION_FONT_SIZE') + ) + + self.legend_font_size = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_LEGEND_FONT_SIZE') + ) + + # Map centered on Pacific Ocean + self.central_longitude = 180.0 + + self.cross_marker_size = (self.config.getint('config', + 'CYCLONE_PLOTTER_CROSS_MARKER_SIZE') + ) + self.resolution_dpi = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_RESOLUTION_DPI') + ) + + self.add_watermark = self.config.getbool('config', + 'CYCLONE_PLOTTER_ADD_WATERMARK', + True) + # Set the marker symbols, '+' for a small cross, '.' for a small circle. + # NOTE: originally, 'o' was used. 'o' is also a circle, but it creates a + # very large filled circle on the plots that appears too large. + self.cross_marker = '+' + self.circle_marker = '.' + + # Map extent, global if CYCLONE_PLOTTER_GLOBAL_PLOT is True + self.is_global_extent = (self.config.getbool('config', + 'CYCLONE_PLOTTER_GLOBAL_PLOT') + ) + self.logger.debug(f"global_extent value: {self.is_global_extent}") + + # User-defined map extent, lons and lats + if self.is_global_extent: + self.logger.debug("Global extent") + else: + self.logger.debug("Getting lons and lats that define the plot's extent") + west_lon = (self.config.getstr('config', + 'CYCLONE_PLOTTER_WEST_LON') + ) + east_lon = (self.config.getstr('config', + 'CYCLONE_PLOTTER_EAST_LON') + ) + north_lat = (self.config.getstr('config', + 'CYCLONE_PLOTTER_NORTH_LAT') + ) + south_lat = (self.config.getstr('config', + 'CYCLONE_PLOTTER_SOUTH_LAT') + ) + + # Check for unconfigured lons and lats needed for defining the extent + if not west_lon: + self.logger.error("Missing CYCLONE_PLOTTER_WEST_LON in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_WEST_LON please check config file") + else: + self.west_lon = (float(west_lon)) + if not east_lon: + self.logger.error("Missing CYCLONE_PLOTTER_EAST_LON in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_EAST_LON please check config file") + else: + self.east_lon = (float(east_lon)) + if not south_lat: + self.logger.error("Missing CYCLONE_PLOTTER_SOUTH_LAT in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_SOUTH_LAT please check config file") + else: + self.south_lat = float(south_lat) + if not north_lat: + self.logger.error("Missing CYCLONE_PLOTTER_NORTH_LAT in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_NORTH_LAT please check config file") + else: + self.north_lat = float(north_lat) + + self.extent_region = [self.west_lon, self.east_lon, self.south_lat, self.north_lat] + self.logger.debug(f"extent region: {self.extent_region}") + + + def run_all_times(self): + """! Calls the defs needed to create the cyclone plots + run_all_times() is required by CommandBuilder. + + """ + self.sanitized_df = self.retrieve_data() + self.create_plot() + + + def retrieve_data(self): + """! Retrieve data from track files. + Returns: + sanitized_df: a pandas dataframe containing the + "sanitized" longitudes, as well as some markers and + lead group information needed for generating + scatter plots. + + """ + self.logger.debug("Begin retrieving data...") + all_tracks_list = [] + + # Store the data in the track list. + if os.path.isdir(self.input_data): + self.logger.debug("Get data from all files in the directory " + + self.input_data) + # Get the list of all files (full file path) in this directory + all_input_files = util.get_files(self.input_data, ".*.tcst", + self.logger) + + # read each file into pandas then concatenate them together + df_list = [pd.read_csv(file, delim_whitespace=True) for file in all_input_files] + combined = pd.concat(df_list, ignore_index=True) + + # check for empty dataframe, set error message and exit + if combined.empty: + self.logger.error("No data found in specified files. Please check your config file settings.") + sys.exit("No data found.") + + # if there are any NaN values in the ALAT, ALON, STORM_ID, LEAD, INIT, AMODEL, or VALID column, + # drop that row of data (axis=0). We need all these columns to contain valid data in order + # to create a meaningful plot. + combined_df = combined.copy(deep=True) + combined_df = combined.dropna(axis=0, how='any', + subset=self.columns_of_interest) + + # Retrieve and create the columns of interest + self.logger.debug(f"Number of rows of data: {combined_df.shape[0]}") + combined_subset = combined_df[self.columns_of_interest] + df = combined_subset.copy(deep=True) + df.allows_duplicate_labels = False + # INIT, LEAD, VALID correspond to the column headers from the MET + # TC tool output. INIT_YMD, INIT_HOUR, VALID_DD, and VALID_HOUR are + # new columns (for a new dataframe) created from these MET columns. + df['INIT'] = df['INIT'].astype(str) + df['INIT_YMD'] = (df['INIT'].str[:8]).astype(int) + df['INIT_HOUR'] = (df['INIT'].str[9:11]).astype(int) + df['LEAD'] = df['LEAD']/10000 + df['LEAD'] = df['LEAD'].astype(int) + df['VALID_DD'] = (df['VALID'].str[6:8]).astype(int) + df['VALID_HOUR'] = (df['VALID'].str[9:11]).astype(int) + df['VALID'] = df['VALID'].astype(int) + + # Subset the dataframe to include only the data relevant to the user's criteria as + # specified in the configuration file. + init_date = int(self.init_date) + init_hh = int(self.init_hr) + model_name = self.model + + if model_name: + self.logger.debug("Subsetting based on " + str(init_date) + " " + str(init_hh) + + ", and model:" + model_name ) + mask = df[(df['AMODEL'] == model_name) & (df['INIT_YMD'] >= init_date) & + (df['INIT_HOUR'] >= init_hh)] + else: + # no model specified, just subset on init date and init hour + mask = df[(df['INIT_YMD'] >= init_date) & + (df['INIT_HOUR'] >= init_hh)] + self.logger.debug("Subsetting based on " + str(init_date) + ", and "+ str(init_hh)) + + user_criteria_df = mask + # reset the index so things are ordered properly in the new dataframe + user_criteria_df.reset_index(inplace=True) + + # Aggregate the ALON values based on unique storm id to facilitate "sanitizing" the + # longitude values (to handle lons that cross the International Date Line). + unique_storm_ids_set = set(user_criteria_df['STORM_ID']) + self.unique_storm_ids = list(unique_storm_ids_set) + nunique = len(self.unique_storm_ids) + self.logger.debug(f" {nunique} unique storm ids identified") + + # Use named tuples to store the relevant storm track information (their index value in the dataframe, + # track id, and ALON values and later on the SLON (sanitized ALON values). + TrackPt = namedtuple("TrackPt", "indices track alons alats") + + # named tuple holding "sanitized" longitudes + SanTrackPt = namedtuple("SanTrackPt", "indices track alons slons") + + # Keep track of the unique storm tracks by their storm_id + storm_track_dict = {} + + for cur_unique in self.unique_storm_ids: + idx_list = user_criteria_df.index[user_criteria_df['STORM_ID'] == cur_unique].tolist() + alons = [] + alats = [] + indices = [] + + for idx in idx_list: + alons.append(user_criteria_df.loc[idx, 'ALON']) + alats.append(user_criteria_df.loc[idx, 'ALAT']) + indices.append(idx) + + # create the track_pt tuple and add it to the storm track dictionary + track_pt = TrackPt(indices, cur_unique, alons, alats) + storm_track_dict[cur_unique] = track_pt + + # create a new dataframe to contain the sanitized lons (i.e. the original ALONs that have + # been cleaned up when crossing the International Date Line) + sanitized_df = user_criteria_df.copy(deep=True) + + # Now we have a dictionary that helps in aggregating the data based on + # storm tracks (via storm id) and will contain the "sanitized" lons + sanitized_storm_tracks = {} + for key in storm_track_dict: + # "Sanitize" the longitudes to shift the lons that cross the International Date Line. + # Create a new SanTrackPt named tuple and add that to a new dictionary + # that keeps track of the sanitized data based on the storm id + # sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) + sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) + sanitized_track_pt = SanTrackPt(storm_track_dict[key].indices, storm_track_dict[key].track, + storm_track_dict[key].alons, sanitized_lons) + sanitized_storm_tracks[key] = sanitized_track_pt + + # fill in the sanitized dataframe, sanitized_df + for key in sanitized_storm_tracks: + # now use the indices of the storm tracks to correctly assign the sanitized + # lons to the appropriate row in the dataframe to maintain the row ordering of + # the original dataframe + idx_list = sanitized_storm_tracks[key].indices + + for i, idx in enumerate(idx_list): + sanitized_df.loc[idx,'SLON'] = sanitized_storm_tracks[key].slons[i] + + # Set some useful values used for plotting. + # Set the IS_FIRST value to True if this is the first + # point in the storm track, False + # otherwise + if i == 0: + sanitized_df.loc[idx, 'IS_FIRST'] = True + else: + sanitized_df.loc[idx, 'IS_FIRST'] = False + + # Set the lead group to the character '0' if the valid hour is 0 or 12, + # or to the charcter '6' if the valid hour is 6 or 18. Set the marker + # to correspond to the valid hour: 'o' (open circle) for 0 or 12 valid hour, + # or '+' (small plus/cross) for 6 or 18. + if sanitized_df.loc[idx, 'VALID_HOUR'] == 0 or sanitized_df.loc[idx, 'VALID_HOUR'] == 12: + sanitized_df.loc[idx, 'LEAD_GROUP'] ='0' + sanitized_df.loc[idx, 'MARKER'] = self.circle_marker + elif sanitized_df.loc[idx, 'VALID_HOUR'] == 6 or sanitized_df.loc[idx, 'VALID_HOUR'] == 18: + sanitized_df.loc[idx, 'LEAD_GROUP'] = '6' + sanitized_df.loc[idx, 'MARKER'] = self.cross_marker + + # If the user has specified a region of interest rather than the + # global extent, subset the data even further to points that are within a bounding box. + if not self.is_global_extent: + self.logger.debug(f"Subset the data based on the region of interest.") + subset_by_region_df = self.subset_by_region(sanitized_df) + final_df = subset_by_region_df.copy(deep=True) + else: + final_df = sanitized_df.copy(deep=True) + + # Write output ASCII file (csv) summarizing the information extracted from the input + # which is used to generate the plot. + if self.gen_ascii: + self.logger.debug(f" output dir: {self.output_dir}") + util.mkdir_p(self.output_dir) + ascii_track_parts = [self.init_date, '.csv'] + ascii_track_output_name = ''.join(ascii_track_parts) + final_df_filename = os.path.join(self.output_dir, ascii_track_output_name) + + # Make sure that the dataframe is sorted by STORM_ID, INIT_YMD, INIT_HOUR, and LEAD + # to ensure that the line plot is connecting the points in the correct order. + final_sorted_df = final_df.sort_values(by=['STORM_ID', 'INIT_YMD', 'INIT_HOUR', 'LEAD'], ignore_index=True) + final_df.reset_index(drop=True,inplace=True) + final_sorted_df.to_csv(final_df_filename) + else: + # The user's specified directory isn't valid, log the error and exit. + self.logger.error("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory, check config file.") + sys.exit("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory.") + + return final_sorted_df + + + def create_plot(self): + """ + Create the plot, using Cartopy + + """ + + # Use PlateCarree projection for scatter plots + # and Geodetic projection for line plots. + cm_lon = self.central_longitude + prj = ccrs.PlateCarree(central_longitude=cm_lon) + ax = plt.axes(projection=prj) + + # for transforming the annotations (matplotlib to cartopy workaround from Stack Overflow) + transform = ccrs.PlateCarree()._as_mpl_transform(ax) + + # Add land, coastlines, and ocean + ax.add_feature(cfeature.LAND) + ax.coastlines() + ax.add_feature(cfeature.OCEAN) + + # keep map zoomed out to full world (ie global extent) if CYCLONE_PLOTTER_GLOBAL_PLOT is + # yes or True, otherwise use the lons and lats defined in the config file to + # create a polygon (rectangular box) defining the region of interest. + if self.is_global_extent: + ax.set_global() + self.logger.debug("Generating a plot of the global extent") + else: + self.logger.debug(f"Generating a plot of the user-defined extent:{self.west_lon}, {self.east_lon}, " + f"{self.south_lat}, {self.north_lat}") + extent_list = [self.west_lon, self.east_lon, self.south_lat, self.north_lat] + self.logger.debug(f"Setting map extent to: {self.west_lon}, {self.east_lon}, {self.south_lat}, {self.north_lat}") + # Bounding box will not necessarily be centered about the 180 degree longitude, so + # DO NOT explicitly set the central longitude. + ax.set_extent(extent_list, ccrs.PlateCarree()) + + # Add grid lines for longitude and latitude + gl = ax.gridlines(crs=ccrs.PlateCarree(), + draw_labels=True, linewidth=1, color='gray', + alpha=0.5, linestyle='--') + + gl.top_labels = False + gl.left_labels = True + gl.xlines = True + gl.xformatter = LONGITUDE_FORMATTER + gl.yformatter = LATITUDE_FORMATTER + gl.xlabel_style = {'size': 9, 'color': 'blue'} + gl.xlabel_style = {'color': 'black', 'weight': 'normal'} + + # Plot title + plt.title(self.title + "\nFor forecast with initial time = " + + self.init_date) + + # Optional: Create the NCAR watermark with a timestamp + # This will appear in the bottom right corner of the plot, below + # the x-axis. NOTE: The timestamp is in the user's local time zone + # and not in UTC time. + if self.add_watermark: + ts = time.time() + st = datetime.datetime.fromtimestamp(ts).strftime( + '%Y-%m-%d %H:%M:%S') + watermark = 'DTC METplus\nplot created at: ' + st + plt.text(60, -130, watermark, fontsize=5, alpha=0.25) + + # Make sure the output directory exists, and create it if it doesn't. + util.mkdir_p(self.output_dir) + + # get the points for the scatter plots (and the relevant information for annotations, etc.) + points_list = self.get_plot_points() + + # Legend labels + lead_group_0_legend = "Indicates a position at 00 or 12 UTC" + lead_group_6_legend = "Indicates a position at 06 or 18 UTC" + + # to be consistent with the NOAA website, use red for annotations, markers, and lines. + pt_color = 'red' + cross_marker_size = self.cross_marker_size + circle_marker_size = self.circle_marker_size + + # Get all the lat and lon (i.e. x and y) points for the '+' and 'o' marker types + # to be used in generating the scatter plots (one for the 0/12 hr and one for the 6/18 hr lead + # groups). Also collect ALL the lons and lats, which will be used to generate the + # line plot (the last plot that goes on top of all the scatter plots). + cross_lons = [] + cross_lats = [] + cross_annotations = [] + circle_lons = [] + circle_lats = [] + circle_annotations = [] + + for idx,pt in enumerate(points_list): + if pt.marker == self.cross_marker: + cross_lons.append(pt.lon) + cross_lats.append(pt.lat) + cross_annotations.append(pt.annotation) + # cross_marker = pt.marker + elif pt.marker == self.circle_marker: + circle_lons.append(pt.lon) + circle_lats.append(pt.lat) + circle_annotations.append(pt.annotation) + # circle_marker = pt.marker + + # Now generate the scatter plots for the lead group 0/12 hr ('+' marker) and the + # lead group 6/18 hr ('.' marker). + plt.scatter(circle_lons, circle_lats, s=self.circle_marker_size, c=pt_color, + marker=self.circle_marker, zorder=2, label=lead_group_0_legend, transform=ccrs.PlateCarree()) + plt.scatter(cross_lons, cross_lats, s=self.cross_marker_size, c=pt_color, + marker=self.cross_marker, zorder=2, label=lead_group_6_legend, transform=ccrs.PlateCarree()) + + # annotations for the scatter plots + counter = 0 + for x,y in zip(circle_lons, circle_lats): + plt.annotate(circle_annotations[counter], (x,y+1), xycoords=transform, color=pt_color, + fontsize=self.annotation_font_size) + counter += 1 + + counter = 0 + for x, y in zip(cross_lons, cross_lats): + plt.annotate(cross_annotations[counter], (x, y + 1), xycoords=transform, color=pt_color, + fontsize=self.annotation_font_size) + counter += 1 + + # Dummy point to add the additional label explaining the labelling of the first + # point in the storm track + plt.scatter(0, 0, zorder=2, marker=None, c='', + label="Date (dd/hhz) is the first " + + "time storm was able to be tracked in model") + + # Settings for the legend box location. + ax.legend(loc='lower left', bbox_to_anchor=(0, -0.4), + fancybox=True, shadow=True, scatterpoints=1, + prop={'size':self.legend_font_size}) + + # Generate the line plot + # First collect all the lats and lons for each storm track. Then for each storm track, + # generate a line plot. + pts_by_track_dict = self.get_points_by_track() + + for key in pts_by_track_dict: + lons = [] + lats = [] + for idx, pt in enumerate(pts_by_track_dict[key]): + lons.append(pt.lon) + lats.append(pt.lat) + + # Create the line plot for the current storm track, use the Geodetic coordinate reference system + # to correctly connect adjacent points that have been sanitized and cross the + # International Date line or the Prime Meridian. + plt.plot(lons, lats, linestyle='-', color=pt_color, linewidth=.4, transform=ccrs.Geodetic(), zorder=3) + + # Write the plot to the output directory + out_filename_parts = [self.init_date, '.png'] + output_plot_name = ''.join(out_filename_parts) + plot_filename = os.path.join(self.output_dir, output_plot_name) + if self.resolution_dpi > 0: + plt.savefig(plot_filename, dpi=self.resolution_dpi) + else: + # use Matplotlib's default if no resolution is set in config file + plt.savefig(plot_filename) + + + def get_plot_points(self): + """ + Get the lon and lat points to be plotted, along with any other plotting-relevant + information like the marker, whether this is a first point (to be used in + annotating the first point using the valid day date and valid hour), etc. + + :return: A list of named tuples that represent the points to plot with corresponding + plotting information + """ + + # Create a named tuple to store the point information + PlotPt = namedtuple("PlotPt", "storm_id lon lat is_first marker valid_dd valid_hour annotation") + + points_list = [] + storm_id = self.sanitized_df['STORM_ID'] + lons = self.sanitized_df['SLON'] + lats = self.sanitized_df['ALAT'] + is_first_list = self.sanitized_df['IS_FIRST'] + marker_list = self.sanitized_df['MARKER'] + valid_dd_list = self.sanitized_df['VALID_DD'] + valid_hour_list = self.sanitized_df['VALID_HOUR'] + annotation_list = [] + + for idx, cur_lon in enumerate(lons): + if is_first_list[idx] is True: + annotation = str(valid_dd_list[idx]).zfill(2) + '/' + \ + str(valid_hour_list[idx]).zfill(2) + 'z' + else: + annotation = None + + annotation_list.append(annotation) + cur_pt = PlotPt(storm_id, lons[idx], lats[idx], is_first_list[idx], marker_list[idx], + valid_dd_list[idx], valid_hour_list[idx], annotation) + points_list.append(cur_pt) + + return points_list + + + def get_points_by_track(self): + """ + Get all the lats and lons for each storm track. Used to generate the line + plot of the storm tracks. + + Args: + + :return: + points_by_track: Points aggregated by storm track. + Returns a dictionary where the key is the storm_id + and values are the points (lon,lat) stored in a named tuple + """ + track_dict = {} + LonLat = namedtuple("LonLat", "lon lat") + for cur_unique in self.unique_storm_ids: + # retrieve the ALAT and ALON values that correspond to the rows for a unique storm id. + # i.e. Get the index value(s) corresponding to this unique storm id + idx_list = self.sanitized_df.index[self.sanitized_df['STORM_ID'] == cur_unique].tolist() + sanitized_lons_and_lats = [] + indices = [] + for idx in idx_list: + cur_lonlat = LonLat(self.sanitized_df.loc[idx, 'SLON'], self.sanitized_df.loc[idx, 'ALAT']) + sanitized_lons_and_lats.append(cur_lonlat) + indices.append(idx) + + # update the track dictionary + track_dict[cur_unique] = sanitized_lons_and_lats + + return track_dict + + + def subset_by_region(self, sanitized_df): + """ + Args: + @param: sanitized_df the pandas dataframe containing + the "sanitized" longitudes and other useful + plotting information + + Returns: + :return: + """ + self.logger.debug("Subsetting by region...") + + # Copy the sanitized_df dataframe + sanitized_by_region_df = sanitized_df.copy(deep=True) + + # Iterate over ALL the rows and if any point is within the polygon, + # save it's index so we can create a new dataframe with just the + # relevant data. + for index, row in sanitized_by_region_df.iterrows(): + if (self.west_lon <= row['ALON'] <= self.east_lon) and (self.south_lat <= row['ALAT'] <= self.north_lat): + sanitized_by_region_df.loc[index,'INSIDE'] = True + else: + sanitized_by_region_df.loc[index,'INSIDE'] = False + + # Now filter the input dataframe based on the whether points are inside + # the specified boundaries. + masked = sanitized_by_region_df[sanitized_by_region_df['INSIDE'] == True] + masked.reset_index(drop=True,inplace=True) + + if len(masked) == 0: + sys.exit("No data in region specified, please check your lon and lat values in the config file.") + + return masked + + + @staticmethod + def sanitize_lonlist(lon_list): + """ + Solution from Stack Overflow for "sanitizing" longitudes that cross the International Date Line + https://stackoverflow.com/questions/67730660/plotting-line-across-international-dateline-with-cartopy + + Args: + @param lon_list: A list of longitudes (float) that correspond to a storm track + + Returns: + new_list: a list of "sanitized" lons that are "corrected" for crossing the + International Date Line + """ + + new_list = [] + oldval = 0 + # used to compare adjacent longitudes in a storm track + treshold = 10 + for ix, ea in enumerate(lon_list): + diff = oldval - ea + if (ix > 0): + if (diff > treshold): + ea = ea + 360 + oldval = ea + new_list.append(ea) + return new_list \ No newline at end of file diff --git a/parm/use_cases/met_tool_wrapper/CyclonePlotter/CyclonePlotter.conf b/parm/use_cases/met_tool_wrapper/CyclonePlotter/CyclonePlotter.conf index 9d8ce2730b..aa3d15a506 100644 --- a/parm/use_cases/met_tool_wrapper/CyclonePlotter/CyclonePlotter.conf +++ b/parm/use_cases/met_tool_wrapper/CyclonePlotter/CyclonePlotter.conf @@ -24,6 +24,37 @@ CYCLONE_PLOTTER_INIT_DATE = 20150301 CYCLONE_PLOTTER_INIT_HR = 12 ;; hh format CYCLONE_PLOTTER_MODEL = GFSO CYCLONE_PLOTTER_PLOT_TITLE = Model Forecast Storm Tracks +## +# Indicate the region of the globe to plot +# + +# Set to Y[y]es or True to plot entire global extent. N[n]o or False +# to generate a plot of a defined region of the world, then define lons and +# lats below. +CYCLONE_PLOTTER_GLOBAL_PLOT = no + +## +# Indicate the region (i.e. define a bounding box) to plot +# + +# Set to Y[y]es or True to plot entire global extent, N[n]o or False +# to generate a plot of a defined region of the world (and define lons and +# lats below). +CYCLONE_PLOTTER_GLOBAL_PLOT = no + +# ***IMPORTANT*** If CYCLONE_PLOTTER_GLOBAL_PLOT +# is set to False or N[n]o, then define the region of the world to plot. +# Longitudes can range from -180 to 180 degrees and latitudes from -90 to 90 degrees + +# -------------------------------- +# EXAMPLE OF BOUNDING BOX SETTINGS +# -------------------------------- +# NORTHERN HEMISPHERE +CYCLONE_PLOTTER_WEST_LON = -180 +CYCLONE_PLOTTER_EAST_LON = 179 +CYCLONE_PLOTTER_SOUTH_LAT = 0 +CYCLONE_PLOTTER_NORTH_LAT = 90 + ## # Indicate the size of symbol (point size) diff --git a/parm/use_cases/model_applications/tc_and_extra_tc/CyclonePlotter_fcstGFS_obsGFS_UserScript_ExtraTC.conf b/parm/use_cases/model_applications/tc_and_extra_tc/CyclonePlotter_fcstGFS_obsGFS_UserScript_ExtraTC.conf index 94a0773846..3549ba23c5 100644 --- a/parm/use_cases/model_applications/tc_and_extra_tc/CyclonePlotter_fcstGFS_obsGFS_UserScript_ExtraTC.conf +++ b/parm/use_cases/model_applications/tc_and_extra_tc/CyclonePlotter_fcstGFS_obsGFS_UserScript_ExtraTC.conf @@ -103,11 +103,32 @@ CYCLONE_PLOTTER_INIT_HR ={init?fmt=%H} CYCLONE_PLOTTER_MODEL = GFSO CYCLONE_PLOTTER_PLOT_TITLE = Model Forecast Storm Tracks +## +# Indicate the region (i.e. define a bounding box) to plot +# + +# Set to Y[y]es or True to plot entire global extent, N[n]o or False +# to generate a plot of a defined region of the world (and define lons and +# lats below). +CYCLONE_PLOTTER_GLOBAL_PLOT = no + +# ***IMPORTANT*** If CYCLONE_PLOTTER_GLOBAL_PLOT +# is set to False or N[n]o, then define the region of the world to plot. +# Longitudes can range from -180 to 180 degrees and latitudes from -90 to 90 degrees + +# -------------------------------- +# EXAMPLE OF BOUNDING BOX SETTINGS +# -------------------------------- +# NORTHERN HEMISPHERE +CYCLONE_PLOTTER_WEST_LON = -180 +CYCLONE_PLOTTER_EAST_LON = 179 +CYCLONE_PLOTTER_SOUTH_LAT = 0 +CYCLONE_PLOTTER_NORTH_LAT = 90 ## # Indicate the size of symbol (point size) -CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE = 2 -CYCLONE_PLOTTER_CROSS_MARKER_SIZE = 3 +CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE = 4 +CYCLONE_PLOTTER_CROSS_MARKER_SIZE = 6 ## # Indicate text size of annotation label diff --git a/parm/use_cases/model_applications/tc_and_extra_tc/Plotter_fcstGFS_obsGFS_ExtraTC.conf b/parm/use_cases/model_applications/tc_and_extra_tc/Plotter_fcstGFS_obsGFS_ExtraTC.conf index 7734c929d9..cd213f47ac 100644 --- a/parm/use_cases/model_applications/tc_and_extra_tc/Plotter_fcstGFS_obsGFS_ExtraTC.conf +++ b/parm/use_cases/model_applications/tc_and_extra_tc/Plotter_fcstGFS_obsGFS_ExtraTC.conf @@ -26,7 +26,6 @@ TC_PAIRS_OUTPUT_TEMPLATE = {date?fmt=%Y%m}/{basin?fmt=%s}q{date?fmt=%Y%m%d%H}.gf # EXTRA TROPICAL CYCLONE PLOT OPTIONS... # PROCESS_LIST = TCPairs, CyclonePlotter - LOOP_ORDER = processes LOOP_BY = init @@ -134,6 +133,27 @@ CYCLONE_PLOTTER_MODEL = GFSO CYCLONE_PLOTTER_PLOT_TITLE = Model Forecast Storm Tracks ## +# Indicate the region (i.e. define a bounding box) to plot +# + +# Set to Y[y]es or True to plot entire global extent, N[n]o or False +# to generate a plot of a defined region of the world (and define lons and +# lats below). +CYCLONE_PLOTTER_GLOBAL_PLOT = no + +# ***IMPORTANT*** If CYCLONE_PLOTTER_GLOBAL_PLOT +# is set to False or N[n]o, then define the region of the world to plot. +# Longitudes can range from -180 to 180 degrees and latitudes from -90 to 90 degrees + +# -------------------------------- +# EXAMPLE OF BOUNDING BOX SETTINGS +# -------------------------------- +# NORTHERN HEMISPHERE +CYCLONE_PLOTTER_WEST_LON = -180 +CYCLONE_PLOTTER_EAST_LON = 179 +CYCLONE_PLOTTER_SOUTH_LAT = 0 +CYCLONE_PLOTTER_NORTH_LAT = 90 + # Indicate the size of symbol (point size) CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE = 2 CYCLONE_PLOTTER_CROSS_MARKER_SIZE = 3 From ee5e27457a6aace261ef31fc7a81f258d0f4a815 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 3 Nov 2021 15:47:35 -0600 Subject: [PATCH 02/43] convert file back to unix format via dos2unix and added change that was lost in merge --- metplus/wrappers/cyclone_plotter_wrapper.py | 1336 +++++++++---------- 1 file changed, 668 insertions(+), 668 deletions(-) diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index 5c177f6fc9..7a5870e4b5 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -1,668 +1,668 @@ - -"""!@namespace ExtraTropicalCyclonePlotter -A Python class that generates plots of extra tropical cyclone forecast data, - replicating the NCEP tropical and extra tropical cyclone tracks and - verification plots http://www.emc.ncep.noaa.gov/mmb/gplou/emchurr/glblgen/ -""" - -import os -import time -import datetime -import re -import sys -from collections import namedtuple - - -# handle if module can't be loaded to run wrapper -WRAPPER_CANNOT_RUN = False -EXCEPTION_ERR = '' -try: - import pandas as pd - import matplotlib.pyplot as plt - import matplotlib.ticker as mticker - import cartopy.crs as ccrs - import cartopy.feature as cfeature - import cartopy - from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER - - ##If the script is run on a limited-internet access machine, the CARTOPY_DIR environment setting - ##will need to be set in the user-specific system configuration file. Review the Installation section - ##of the User's Guide for more details. - if os.getenv('CARTOPY_DIR'): - cartopy.config['data_dir'] = os.getenv('CARTOPY_DIR', cartopy.config.get('data_dir')) - -except Exception as err_msg: - WRAPPER_CANNOT_RUN = True - EXCEPTION_ERR = err_msg - -import produtil.setup - -from ..util import met_util as util -from ..util import do_string_sub -from . import CommandBuilder - - -class CyclonePlotterWrapper(CommandBuilder): - """! Generate plots of extra tropical storm forecast tracks. - Reads input from ATCF files generated from MET TC-Pairs - """ - - def __init__(self, config, instance=None, config_overrides={}): - self.app_name = 'cyclone_plotter' - - super().__init__(config, - instance=instance, - config_overrides=config_overrides) - - if WRAPPER_CANNOT_RUN: - self.log_error("There was a problem importing modules: " - f"{EXCEPTION_ERR}\n") - return - - self.input_data = self.config.getdir('CYCLONE_PLOTTER_INPUT_DIR') - self.output_dir = self.config.getdir('CYCLONE_PLOTTER_OUTPUT_DIR') - self.init_date = self.config.getraw('config', - 'CYCLONE_PLOTTER_INIT_DATE') - self.init_hr = self.config.getraw('config', 'CYCLONE_PLOTTER_INIT_HR') - - init_time_fmt = self.config.getstr('config', 'INIT_TIME_FMT', '') - - if init_time_fmt: - clock_time = datetime.datetime.strptime( - self.config.getstr('config', - 'CLOCK_TIME'), - '%Y%m%d%H%M%S' - ) - - init_beg = self.config.getraw('config', 'INIT_BEG') - if init_beg: - init_beg_dt = util.get_time_obj(init_beg, - init_time_fmt, - clock_time, - logger=self.logger) - self.init_date = do_string_sub(self.init_date, init=init_beg_dt) - self.init_hr = do_string_sub(self.init_hr, init=init_beg_dt) - - self.model = self.config.getstr('config', 'CYCLONE_PLOTTER_MODEL') - self.title = self.config.getstr('config', - 'CYCLONE_PLOTTER_PLOT_TITLE') - self.gen_ascii = ( - self.config.getbool('config', - 'CYCLONE_PLOTTER_GENERATE_TRACK_ASCII') - ) - # Create a set to keep track of unique storm_ids for each track file. - self.unique_storm_id = set() - # Data structure to separate data based on storm id. - self.storm_id_dict = {} - - # Data/info which we want to retrieve from the track files. - self.columns_of_interest = ['AMODEL', 'STORM_ID', 'INIT', - 'LEAD', 'VALID', 'ALAT', 'ALON'] - self.circle_marker_size = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE') - ) - self.annotation_font_size = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_ANNOTATION_FONT_SIZE') - ) - - self.legend_font_size = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_LEGEND_FONT_SIZE') - ) - - # Map centered on Pacific Ocean - self.central_longitude = 180.0 - - self.cross_marker_size = (self.config.getint('config', - 'CYCLONE_PLOTTER_CROSS_MARKER_SIZE') - ) - self.resolution_dpi = ( - self.config.getint('config', - 'CYCLONE_PLOTTER_RESOLUTION_DPI') - ) - - self.add_watermark = self.config.getbool('config', - 'CYCLONE_PLOTTER_ADD_WATERMARK', - True) - # Set the marker symbols, '+' for a small cross, '.' for a small circle. - # NOTE: originally, 'o' was used. 'o' is also a circle, but it creates a - # very large filled circle on the plots that appears too large. - self.cross_marker = '+' - self.circle_marker = '.' - - # Map extent, global if CYCLONE_PLOTTER_GLOBAL_PLOT is True - self.is_global_extent = (self.config.getbool('config', - 'CYCLONE_PLOTTER_GLOBAL_PLOT') - ) - self.logger.debug(f"global_extent value: {self.is_global_extent}") - - # User-defined map extent, lons and lats - if self.is_global_extent: - self.logger.debug("Global extent") - else: - self.logger.debug("Getting lons and lats that define the plot's extent") - west_lon = (self.config.getstr('config', - 'CYCLONE_PLOTTER_WEST_LON') - ) - east_lon = (self.config.getstr('config', - 'CYCLONE_PLOTTER_EAST_LON') - ) - north_lat = (self.config.getstr('config', - 'CYCLONE_PLOTTER_NORTH_LAT') - ) - south_lat = (self.config.getstr('config', - 'CYCLONE_PLOTTER_SOUTH_LAT') - ) - - # Check for unconfigured lons and lats needed for defining the extent - if not west_lon: - self.logger.error("Missing CYCLONE_PLOTTER_WEST_LON in config file. ") - sys.exit("Missing the CYCLONE_PLOTTER_WEST_LON please check config file") - else: - self.west_lon = (float(west_lon)) - if not east_lon: - self.logger.error("Missing CYCLONE_PLOTTER_EAST_LON in config file. ") - sys.exit("Missing the CYCLONE_PLOTTER_EAST_LON please check config file") - else: - self.east_lon = (float(east_lon)) - if not south_lat: - self.logger.error("Missing CYCLONE_PLOTTER_SOUTH_LAT in config file. ") - sys.exit("Missing the CYCLONE_PLOTTER_SOUTH_LAT please check config file") - else: - self.south_lat = float(south_lat) - if not north_lat: - self.logger.error("Missing CYCLONE_PLOTTER_NORTH_LAT in config file. ") - sys.exit("Missing the CYCLONE_PLOTTER_NORTH_LAT please check config file") - else: - self.north_lat = float(north_lat) - - self.extent_region = [self.west_lon, self.east_lon, self.south_lat, self.north_lat] - self.logger.debug(f"extent region: {self.extent_region}") - - - def run_all_times(self): - """! Calls the defs needed to create the cyclone plots - run_all_times() is required by CommandBuilder. - - """ - self.sanitized_df = self.retrieve_data() - self.create_plot() - - - def retrieve_data(self): - """! Retrieve data from track files. - Returns: - sanitized_df: a pandas dataframe containing the - "sanitized" longitudes, as well as some markers and - lead group information needed for generating - scatter plots. - - """ - self.logger.debug("Begin retrieving data...") - all_tracks_list = [] - - # Store the data in the track list. - if os.path.isdir(self.input_data): - self.logger.debug("Get data from all files in the directory " + - self.input_data) - # Get the list of all files (full file path) in this directory - all_input_files = util.get_files(self.input_data, ".*.tcst", - self.logger) - - # read each file into pandas then concatenate them together - df_list = [pd.read_csv(file, delim_whitespace=True) for file in all_input_files] - combined = pd.concat(df_list, ignore_index=True) - - # check for empty dataframe, set error message and exit - if combined.empty: - self.logger.error("No data found in specified files. Please check your config file settings.") - sys.exit("No data found.") - - # if there are any NaN values in the ALAT, ALON, STORM_ID, LEAD, INIT, AMODEL, or VALID column, - # drop that row of data (axis=0). We need all these columns to contain valid data in order - # to create a meaningful plot. - combined_df = combined.copy(deep=True) - combined_df = combined.dropna(axis=0, how='any', - subset=self.columns_of_interest) - - # Retrieve and create the columns of interest - self.logger.debug(f"Number of rows of data: {combined_df.shape[0]}") - combined_subset = combined_df[self.columns_of_interest] - df = combined_subset.copy(deep=True) - df.allows_duplicate_labels = False - # INIT, LEAD, VALID correspond to the column headers from the MET - # TC tool output. INIT_YMD, INIT_HOUR, VALID_DD, and VALID_HOUR are - # new columns (for a new dataframe) created from these MET columns. - df['INIT'] = df['INIT'].astype(str) - df['INIT_YMD'] = (df['INIT'].str[:8]).astype(int) - df['INIT_HOUR'] = (df['INIT'].str[9:11]).astype(int) - df['LEAD'] = df['LEAD']/10000 - df['LEAD'] = df['LEAD'].astype(int) - df['VALID_DD'] = (df['VALID'].str[6:8]).astype(int) - df['VALID_HOUR'] = (df['VALID'].str[9:11]).astype(int) - df['VALID'] = df['VALID'].astype(int) - - # Subset the dataframe to include only the data relevant to the user's criteria as - # specified in the configuration file. - init_date = int(self.init_date) - init_hh = int(self.init_hr) - model_name = self.model - - if model_name: - self.logger.debug("Subsetting based on " + str(init_date) + " " + str(init_hh) + - ", and model:" + model_name ) - mask = df[(df['AMODEL'] == model_name) & (df['INIT_YMD'] >= init_date) & - (df['INIT_HOUR'] >= init_hh)] - else: - # no model specified, just subset on init date and init hour - mask = df[(df['INIT_YMD'] >= init_date) & - (df['INIT_HOUR'] >= init_hh)] - self.logger.debug("Subsetting based on " + str(init_date) + ", and "+ str(init_hh)) - - user_criteria_df = mask - # reset the index so things are ordered properly in the new dataframe - user_criteria_df.reset_index(inplace=True) - - # Aggregate the ALON values based on unique storm id to facilitate "sanitizing" the - # longitude values (to handle lons that cross the International Date Line). - unique_storm_ids_set = set(user_criteria_df['STORM_ID']) - self.unique_storm_ids = list(unique_storm_ids_set) - nunique = len(self.unique_storm_ids) - self.logger.debug(f" {nunique} unique storm ids identified") - - # Use named tuples to store the relevant storm track information (their index value in the dataframe, - # track id, and ALON values and later on the SLON (sanitized ALON values). - TrackPt = namedtuple("TrackPt", "indices track alons alats") - - # named tuple holding "sanitized" longitudes - SanTrackPt = namedtuple("SanTrackPt", "indices track alons slons") - - # Keep track of the unique storm tracks by their storm_id - storm_track_dict = {} - - for cur_unique in self.unique_storm_ids: - idx_list = user_criteria_df.index[user_criteria_df['STORM_ID'] == cur_unique].tolist() - alons = [] - alats = [] - indices = [] - - for idx in idx_list: - alons.append(user_criteria_df.loc[idx, 'ALON']) - alats.append(user_criteria_df.loc[idx, 'ALAT']) - indices.append(idx) - - # create the track_pt tuple and add it to the storm track dictionary - track_pt = TrackPt(indices, cur_unique, alons, alats) - storm_track_dict[cur_unique] = track_pt - - # create a new dataframe to contain the sanitized lons (i.e. the original ALONs that have - # been cleaned up when crossing the International Date Line) - sanitized_df = user_criteria_df.copy(deep=True) - - # Now we have a dictionary that helps in aggregating the data based on - # storm tracks (via storm id) and will contain the "sanitized" lons - sanitized_storm_tracks = {} - for key in storm_track_dict: - # "Sanitize" the longitudes to shift the lons that cross the International Date Line. - # Create a new SanTrackPt named tuple and add that to a new dictionary - # that keeps track of the sanitized data based on the storm id - # sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) - sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) - sanitized_track_pt = SanTrackPt(storm_track_dict[key].indices, storm_track_dict[key].track, - storm_track_dict[key].alons, sanitized_lons) - sanitized_storm_tracks[key] = sanitized_track_pt - - # fill in the sanitized dataframe, sanitized_df - for key in sanitized_storm_tracks: - # now use the indices of the storm tracks to correctly assign the sanitized - # lons to the appropriate row in the dataframe to maintain the row ordering of - # the original dataframe - idx_list = sanitized_storm_tracks[key].indices - - for i, idx in enumerate(idx_list): - sanitized_df.loc[idx,'SLON'] = sanitized_storm_tracks[key].slons[i] - - # Set some useful values used for plotting. - # Set the IS_FIRST value to True if this is the first - # point in the storm track, False - # otherwise - if i == 0: - sanitized_df.loc[idx, 'IS_FIRST'] = True - else: - sanitized_df.loc[idx, 'IS_FIRST'] = False - - # Set the lead group to the character '0' if the valid hour is 0 or 12, - # or to the charcter '6' if the valid hour is 6 or 18. Set the marker - # to correspond to the valid hour: 'o' (open circle) for 0 or 12 valid hour, - # or '+' (small plus/cross) for 6 or 18. - if sanitized_df.loc[idx, 'VALID_HOUR'] == 0 or sanitized_df.loc[idx, 'VALID_HOUR'] == 12: - sanitized_df.loc[idx, 'LEAD_GROUP'] ='0' - sanitized_df.loc[idx, 'MARKER'] = self.circle_marker - elif sanitized_df.loc[idx, 'VALID_HOUR'] == 6 or sanitized_df.loc[idx, 'VALID_HOUR'] == 18: - sanitized_df.loc[idx, 'LEAD_GROUP'] = '6' - sanitized_df.loc[idx, 'MARKER'] = self.cross_marker - - # If the user has specified a region of interest rather than the - # global extent, subset the data even further to points that are within a bounding box. - if not self.is_global_extent: - self.logger.debug(f"Subset the data based on the region of interest.") - subset_by_region_df = self.subset_by_region(sanitized_df) - final_df = subset_by_region_df.copy(deep=True) - else: - final_df = sanitized_df.copy(deep=True) - - # Write output ASCII file (csv) summarizing the information extracted from the input - # which is used to generate the plot. - if self.gen_ascii: - self.logger.debug(f" output dir: {self.output_dir}") - util.mkdir_p(self.output_dir) - ascii_track_parts = [self.init_date, '.csv'] - ascii_track_output_name = ''.join(ascii_track_parts) - final_df_filename = os.path.join(self.output_dir, ascii_track_output_name) - - # Make sure that the dataframe is sorted by STORM_ID, INIT_YMD, INIT_HOUR, and LEAD - # to ensure that the line plot is connecting the points in the correct order. - final_sorted_df = final_df.sort_values(by=['STORM_ID', 'INIT_YMD', 'INIT_HOUR', 'LEAD'], ignore_index=True) - final_df.reset_index(drop=True,inplace=True) - final_sorted_df.to_csv(final_df_filename) - else: - # The user's specified directory isn't valid, log the error and exit. - self.logger.error("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory, check config file.") - sys.exit("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory.") - - return final_sorted_df - - - def create_plot(self): - """ - Create the plot, using Cartopy - - """ - - # Use PlateCarree projection for scatter plots - # and Geodetic projection for line plots. - cm_lon = self.central_longitude - prj = ccrs.PlateCarree(central_longitude=cm_lon) - ax = plt.axes(projection=prj) - - # for transforming the annotations (matplotlib to cartopy workaround from Stack Overflow) - transform = ccrs.PlateCarree()._as_mpl_transform(ax) - - # Add land, coastlines, and ocean - ax.add_feature(cfeature.LAND) - ax.coastlines() - ax.add_feature(cfeature.OCEAN) - - # keep map zoomed out to full world (ie global extent) if CYCLONE_PLOTTER_GLOBAL_PLOT is - # yes or True, otherwise use the lons and lats defined in the config file to - # create a polygon (rectangular box) defining the region of interest. - if self.is_global_extent: - ax.set_global() - self.logger.debug("Generating a plot of the global extent") - else: - self.logger.debug(f"Generating a plot of the user-defined extent:{self.west_lon}, {self.east_lon}, " - f"{self.south_lat}, {self.north_lat}") - extent_list = [self.west_lon, self.east_lon, self.south_lat, self.north_lat] - self.logger.debug(f"Setting map extent to: {self.west_lon}, {self.east_lon}, {self.south_lat}, {self.north_lat}") - # Bounding box will not necessarily be centered about the 180 degree longitude, so - # DO NOT explicitly set the central longitude. - ax.set_extent(extent_list, ccrs.PlateCarree()) - - # Add grid lines for longitude and latitude - gl = ax.gridlines(crs=ccrs.PlateCarree(), - draw_labels=True, linewidth=1, color='gray', - alpha=0.5, linestyle='--') - - gl.top_labels = False - gl.left_labels = True - gl.xlines = True - gl.xformatter = LONGITUDE_FORMATTER - gl.yformatter = LATITUDE_FORMATTER - gl.xlabel_style = {'size': 9, 'color': 'blue'} - gl.xlabel_style = {'color': 'black', 'weight': 'normal'} - - # Plot title - plt.title(self.title + "\nFor forecast with initial time = " + - self.init_date) - - # Optional: Create the NCAR watermark with a timestamp - # This will appear in the bottom right corner of the plot, below - # the x-axis. NOTE: The timestamp is in the user's local time zone - # and not in UTC time. - if self.add_watermark: - ts = time.time() - st = datetime.datetime.fromtimestamp(ts).strftime( - '%Y-%m-%d %H:%M:%S') - watermark = 'DTC METplus\nplot created at: ' + st - plt.text(60, -130, watermark, fontsize=5, alpha=0.25) - - # Make sure the output directory exists, and create it if it doesn't. - util.mkdir_p(self.output_dir) - - # get the points for the scatter plots (and the relevant information for annotations, etc.) - points_list = self.get_plot_points() - - # Legend labels - lead_group_0_legend = "Indicates a position at 00 or 12 UTC" - lead_group_6_legend = "Indicates a position at 06 or 18 UTC" - - # to be consistent with the NOAA website, use red for annotations, markers, and lines. - pt_color = 'red' - cross_marker_size = self.cross_marker_size - circle_marker_size = self.circle_marker_size - - # Get all the lat and lon (i.e. x and y) points for the '+' and 'o' marker types - # to be used in generating the scatter plots (one for the 0/12 hr and one for the 6/18 hr lead - # groups). Also collect ALL the lons and lats, which will be used to generate the - # line plot (the last plot that goes on top of all the scatter plots). - cross_lons = [] - cross_lats = [] - cross_annotations = [] - circle_lons = [] - circle_lats = [] - circle_annotations = [] - - for idx,pt in enumerate(points_list): - if pt.marker == self.cross_marker: - cross_lons.append(pt.lon) - cross_lats.append(pt.lat) - cross_annotations.append(pt.annotation) - # cross_marker = pt.marker - elif pt.marker == self.circle_marker: - circle_lons.append(pt.lon) - circle_lats.append(pt.lat) - circle_annotations.append(pt.annotation) - # circle_marker = pt.marker - - # Now generate the scatter plots for the lead group 0/12 hr ('+' marker) and the - # lead group 6/18 hr ('.' marker). - plt.scatter(circle_lons, circle_lats, s=self.circle_marker_size, c=pt_color, - marker=self.circle_marker, zorder=2, label=lead_group_0_legend, transform=ccrs.PlateCarree()) - plt.scatter(cross_lons, cross_lats, s=self.cross_marker_size, c=pt_color, - marker=self.cross_marker, zorder=2, label=lead_group_6_legend, transform=ccrs.PlateCarree()) - - # annotations for the scatter plots - counter = 0 - for x,y in zip(circle_lons, circle_lats): - plt.annotate(circle_annotations[counter], (x,y+1), xycoords=transform, color=pt_color, - fontsize=self.annotation_font_size) - counter += 1 - - counter = 0 - for x, y in zip(cross_lons, cross_lats): - plt.annotate(cross_annotations[counter], (x, y + 1), xycoords=transform, color=pt_color, - fontsize=self.annotation_font_size) - counter += 1 - - # Dummy point to add the additional label explaining the labelling of the first - # point in the storm track - plt.scatter(0, 0, zorder=2, marker=None, c='', - label="Date (dd/hhz) is the first " + - "time storm was able to be tracked in model") - - # Settings for the legend box location. - ax.legend(loc='lower left', bbox_to_anchor=(0, -0.4), - fancybox=True, shadow=True, scatterpoints=1, - prop={'size':self.legend_font_size}) - - # Generate the line plot - # First collect all the lats and lons for each storm track. Then for each storm track, - # generate a line plot. - pts_by_track_dict = self.get_points_by_track() - - for key in pts_by_track_dict: - lons = [] - lats = [] - for idx, pt in enumerate(pts_by_track_dict[key]): - lons.append(pt.lon) - lats.append(pt.lat) - - # Create the line plot for the current storm track, use the Geodetic coordinate reference system - # to correctly connect adjacent points that have been sanitized and cross the - # International Date line or the Prime Meridian. - plt.plot(lons, lats, linestyle='-', color=pt_color, linewidth=.4, transform=ccrs.Geodetic(), zorder=3) - - # Write the plot to the output directory - out_filename_parts = [self.init_date, '.png'] - output_plot_name = ''.join(out_filename_parts) - plot_filename = os.path.join(self.output_dir, output_plot_name) - if self.resolution_dpi > 0: - plt.savefig(plot_filename, dpi=self.resolution_dpi) - else: - # use Matplotlib's default if no resolution is set in config file - plt.savefig(plot_filename) - - - def get_plot_points(self): - """ - Get the lon and lat points to be plotted, along with any other plotting-relevant - information like the marker, whether this is a first point (to be used in - annotating the first point using the valid day date and valid hour), etc. - - :return: A list of named tuples that represent the points to plot with corresponding - plotting information - """ - - # Create a named tuple to store the point information - PlotPt = namedtuple("PlotPt", "storm_id lon lat is_first marker valid_dd valid_hour annotation") - - points_list = [] - storm_id = self.sanitized_df['STORM_ID'] - lons = self.sanitized_df['SLON'] - lats = self.sanitized_df['ALAT'] - is_first_list = self.sanitized_df['IS_FIRST'] - marker_list = self.sanitized_df['MARKER'] - valid_dd_list = self.sanitized_df['VALID_DD'] - valid_hour_list = self.sanitized_df['VALID_HOUR'] - annotation_list = [] - - for idx, cur_lon in enumerate(lons): - if is_first_list[idx] is True: - annotation = str(valid_dd_list[idx]).zfill(2) + '/' + \ - str(valid_hour_list[idx]).zfill(2) + 'z' - else: - annotation = None - - annotation_list.append(annotation) - cur_pt = PlotPt(storm_id, lons[idx], lats[idx], is_first_list[idx], marker_list[idx], - valid_dd_list[idx], valid_hour_list[idx], annotation) - points_list.append(cur_pt) - - return points_list - - - def get_points_by_track(self): - """ - Get all the lats and lons for each storm track. Used to generate the line - plot of the storm tracks. - - Args: - - :return: - points_by_track: Points aggregated by storm track. - Returns a dictionary where the key is the storm_id - and values are the points (lon,lat) stored in a named tuple - """ - track_dict = {} - LonLat = namedtuple("LonLat", "lon lat") - for cur_unique in self.unique_storm_ids: - # retrieve the ALAT and ALON values that correspond to the rows for a unique storm id. - # i.e. Get the index value(s) corresponding to this unique storm id - idx_list = self.sanitized_df.index[self.sanitized_df['STORM_ID'] == cur_unique].tolist() - sanitized_lons_and_lats = [] - indices = [] - for idx in idx_list: - cur_lonlat = LonLat(self.sanitized_df.loc[idx, 'SLON'], self.sanitized_df.loc[idx, 'ALAT']) - sanitized_lons_and_lats.append(cur_lonlat) - indices.append(idx) - - # update the track dictionary - track_dict[cur_unique] = sanitized_lons_and_lats - - return track_dict - - - def subset_by_region(self, sanitized_df): - """ - Args: - @param: sanitized_df the pandas dataframe containing - the "sanitized" longitudes and other useful - plotting information - - Returns: - :return: - """ - self.logger.debug("Subsetting by region...") - - # Copy the sanitized_df dataframe - sanitized_by_region_df = sanitized_df.copy(deep=True) - - # Iterate over ALL the rows and if any point is within the polygon, - # save it's index so we can create a new dataframe with just the - # relevant data. - for index, row in sanitized_by_region_df.iterrows(): - if (self.west_lon <= row['ALON'] <= self.east_lon) and (self.south_lat <= row['ALAT'] <= self.north_lat): - sanitized_by_region_df.loc[index,'INSIDE'] = True - else: - sanitized_by_region_df.loc[index,'INSIDE'] = False - - # Now filter the input dataframe based on the whether points are inside - # the specified boundaries. - masked = sanitized_by_region_df[sanitized_by_region_df['INSIDE'] == True] - masked.reset_index(drop=True,inplace=True) - - if len(masked) == 0: - sys.exit("No data in region specified, please check your lon and lat values in the config file.") - - return masked - - - @staticmethod - def sanitize_lonlist(lon_list): - """ - Solution from Stack Overflow for "sanitizing" longitudes that cross the International Date Line - https://stackoverflow.com/questions/67730660/plotting-line-across-international-dateline-with-cartopy - - Args: - @param lon_list: A list of longitudes (float) that correspond to a storm track - - Returns: - new_list: a list of "sanitized" lons that are "corrected" for crossing the - International Date Line - """ - - new_list = [] - oldval = 0 - # used to compare adjacent longitudes in a storm track - treshold = 10 - for ix, ea in enumerate(lon_list): - diff = oldval - ea - if (ix > 0): - if (diff > treshold): - ea = ea + 360 - oldval = ea - new_list.append(ea) - - return new_list \ No newline at end of file + +"""!@namespace ExtraTropicalCyclonePlotter +A Python class that generates plots of extra tropical cyclone forecast data, + replicating the NCEP tropical and extra tropical cyclone tracks and + verification plots http://www.emc.ncep.noaa.gov/mmb/gplou/emchurr/glblgen/ +""" + +import os +import time +import datetime +import re +import sys +from collections import namedtuple + + +# handle if module can't be loaded to run wrapper +WRAPPER_CANNOT_RUN = False +EXCEPTION_ERR = '' +try: + import pandas as pd + import matplotlib.pyplot as plt + import matplotlib.ticker as mticker + import cartopy.crs as ccrs + import cartopy.feature as cfeature + import cartopy + from cartopy.mpl.gridliner import LONGITUDE_FORMATTER, LATITUDE_FORMATTER + + ##If the script is run on a limited-internet access machine, the CARTOPY_DIR environment setting + ##will need to be set in the user-specific system configuration file. Review the Installation section + ##of the User's Guide for more details. + if os.getenv('CARTOPY_DIR'): + cartopy.config['data_dir'] = os.getenv('CARTOPY_DIR', cartopy.config.get('data_dir')) + +except Exception as err_msg: + WRAPPER_CANNOT_RUN = True + EXCEPTION_ERR = err_msg + +import produtil.setup + +from ..util import met_util as util +from ..util import do_string_sub +from . import CommandBuilder + + +class CyclonePlotterWrapper(CommandBuilder): + """! Generate plots of extra tropical storm forecast tracks. + Reads input from ATCF files generated from MET TC-Pairs + """ + + def __init__(self, config, instance=None, config_overrides=None): + self.app_name = 'cyclone_plotter' + + super().__init__(config, + instance=instance, + config_overrides=config_overrides) + + if WRAPPER_CANNOT_RUN: + self.log_error("There was a problem importing modules: " + f"{EXCEPTION_ERR}\n") + return + + self.input_data = self.config.getdir('CYCLONE_PLOTTER_INPUT_DIR') + self.output_dir = self.config.getdir('CYCLONE_PLOTTER_OUTPUT_DIR') + self.init_date = self.config.getraw('config', + 'CYCLONE_PLOTTER_INIT_DATE') + self.init_hr = self.config.getraw('config', 'CYCLONE_PLOTTER_INIT_HR') + + init_time_fmt = self.config.getstr('config', 'INIT_TIME_FMT', '') + + if init_time_fmt: + clock_time = datetime.datetime.strptime( + self.config.getstr('config', + 'CLOCK_TIME'), + '%Y%m%d%H%M%S' + ) + + init_beg = self.config.getraw('config', 'INIT_BEG') + if init_beg: + init_beg_dt = util.get_time_obj(init_beg, + init_time_fmt, + clock_time, + logger=self.logger) + self.init_date = do_string_sub(self.init_date, init=init_beg_dt) + self.init_hr = do_string_sub(self.init_hr, init=init_beg_dt) + + self.model = self.config.getstr('config', 'CYCLONE_PLOTTER_MODEL') + self.title = self.config.getstr('config', + 'CYCLONE_PLOTTER_PLOT_TITLE') + self.gen_ascii = ( + self.config.getbool('config', + 'CYCLONE_PLOTTER_GENERATE_TRACK_ASCII') + ) + # Create a set to keep track of unique storm_ids for each track file. + self.unique_storm_id = set() + # Data structure to separate data based on storm id. + self.storm_id_dict = {} + + # Data/info which we want to retrieve from the track files. + self.columns_of_interest = ['AMODEL', 'STORM_ID', 'INIT', + 'LEAD', 'VALID', 'ALAT', 'ALON'] + self.circle_marker_size = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_CIRCLE_MARKER_SIZE') + ) + self.annotation_font_size = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_ANNOTATION_FONT_SIZE') + ) + + self.legend_font_size = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_LEGEND_FONT_SIZE') + ) + + # Map centered on Pacific Ocean + self.central_longitude = 180.0 + + self.cross_marker_size = (self.config.getint('config', + 'CYCLONE_PLOTTER_CROSS_MARKER_SIZE') + ) + self.resolution_dpi = ( + self.config.getint('config', + 'CYCLONE_PLOTTER_RESOLUTION_DPI') + ) + + self.add_watermark = self.config.getbool('config', + 'CYCLONE_PLOTTER_ADD_WATERMARK', + True) + # Set the marker symbols, '+' for a small cross, '.' for a small circle. + # NOTE: originally, 'o' was used. 'o' is also a circle, but it creates a + # very large filled circle on the plots that appears too large. + self.cross_marker = '+' + self.circle_marker = '.' + + # Map extent, global if CYCLONE_PLOTTER_GLOBAL_PLOT is True + self.is_global_extent = (self.config.getbool('config', + 'CYCLONE_PLOTTER_GLOBAL_PLOT') + ) + self.logger.debug(f"global_extent value: {self.is_global_extent}") + + # User-defined map extent, lons and lats + if self.is_global_extent: + self.logger.debug("Global extent") + else: + self.logger.debug("Getting lons and lats that define the plot's extent") + west_lon = (self.config.getstr('config', + 'CYCLONE_PLOTTER_WEST_LON') + ) + east_lon = (self.config.getstr('config', + 'CYCLONE_PLOTTER_EAST_LON') + ) + north_lat = (self.config.getstr('config', + 'CYCLONE_PLOTTER_NORTH_LAT') + ) + south_lat = (self.config.getstr('config', + 'CYCLONE_PLOTTER_SOUTH_LAT') + ) + + # Check for unconfigured lons and lats needed for defining the extent + if not west_lon: + self.logger.error("Missing CYCLONE_PLOTTER_WEST_LON in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_WEST_LON please check config file") + else: + self.west_lon = (float(west_lon)) + if not east_lon: + self.logger.error("Missing CYCLONE_PLOTTER_EAST_LON in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_EAST_LON please check config file") + else: + self.east_lon = (float(east_lon)) + if not south_lat: + self.logger.error("Missing CYCLONE_PLOTTER_SOUTH_LAT in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_SOUTH_LAT please check config file") + else: + self.south_lat = float(south_lat) + if not north_lat: + self.logger.error("Missing CYCLONE_PLOTTER_NORTH_LAT in config file. ") + sys.exit("Missing the CYCLONE_PLOTTER_NORTH_LAT please check config file") + else: + self.north_lat = float(north_lat) + + self.extent_region = [self.west_lon, self.east_lon, self.south_lat, self.north_lat] + self.logger.debug(f"extent region: {self.extent_region}") + + + def run_all_times(self): + """! Calls the defs needed to create the cyclone plots + run_all_times() is required by CommandBuilder. + + """ + self.sanitized_df = self.retrieve_data() + self.create_plot() + + + def retrieve_data(self): + """! Retrieve data from track files. + Returns: + sanitized_df: a pandas dataframe containing the + "sanitized" longitudes, as well as some markers and + lead group information needed for generating + scatter plots. + + """ + self.logger.debug("Begin retrieving data...") + all_tracks_list = [] + + # Store the data in the track list. + if os.path.isdir(self.input_data): + self.logger.debug("Get data from all files in the directory " + + self.input_data) + # Get the list of all files (full file path) in this directory + all_input_files = util.get_files(self.input_data, ".*.tcst", + self.logger) + + # read each file into pandas then concatenate them together + df_list = [pd.read_csv(file, delim_whitespace=True) for file in all_input_files] + combined = pd.concat(df_list, ignore_index=True) + + # check for empty dataframe, set error message and exit + if combined.empty: + self.logger.error("No data found in specified files. Please check your config file settings.") + sys.exit("No data found.") + + # if there are any NaN values in the ALAT, ALON, STORM_ID, LEAD, INIT, AMODEL, or VALID column, + # drop that row of data (axis=0). We need all these columns to contain valid data in order + # to create a meaningful plot. + combined_df = combined.copy(deep=True) + combined_df = combined.dropna(axis=0, how='any', + subset=self.columns_of_interest) + + # Retrieve and create the columns of interest + self.logger.debug(f"Number of rows of data: {combined_df.shape[0]}") + combined_subset = combined_df[self.columns_of_interest] + df = combined_subset.copy(deep=True) + df.allows_duplicate_labels = False + # INIT, LEAD, VALID correspond to the column headers from the MET + # TC tool output. INIT_YMD, INIT_HOUR, VALID_DD, and VALID_HOUR are + # new columns (for a new dataframe) created from these MET columns. + df['INIT'] = df['INIT'].astype(str) + df['INIT_YMD'] = (df['INIT'].str[:8]).astype(int) + df['INIT_HOUR'] = (df['INIT'].str[9:11]).astype(int) + df['LEAD'] = df['LEAD']/10000 + df['LEAD'] = df['LEAD'].astype(int) + df['VALID_DD'] = (df['VALID'].str[6:8]).astype(int) + df['VALID_HOUR'] = (df['VALID'].str[9:11]).astype(int) + df['VALID'] = df['VALID'].astype(int) + + # Subset the dataframe to include only the data relevant to the user's criteria as + # specified in the configuration file. + init_date = int(self.init_date) + init_hh = int(self.init_hr) + model_name = self.model + + if model_name: + self.logger.debug("Subsetting based on " + str(init_date) + " " + str(init_hh) + + ", and model:" + model_name ) + mask = df[(df['AMODEL'] == model_name) & (df['INIT_YMD'] >= init_date) & + (df['INIT_HOUR'] >= init_hh)] + else: + # no model specified, just subset on init date and init hour + mask = df[(df['INIT_YMD'] >= init_date) & + (df['INIT_HOUR'] >= init_hh)] + self.logger.debug("Subsetting based on " + str(init_date) + ", and "+ str(init_hh)) + + user_criteria_df = mask + # reset the index so things are ordered properly in the new dataframe + user_criteria_df.reset_index(inplace=True) + + # Aggregate the ALON values based on unique storm id to facilitate "sanitizing" the + # longitude values (to handle lons that cross the International Date Line). + unique_storm_ids_set = set(user_criteria_df['STORM_ID']) + self.unique_storm_ids = list(unique_storm_ids_set) + nunique = len(self.unique_storm_ids) + self.logger.debug(f" {nunique} unique storm ids identified") + + # Use named tuples to store the relevant storm track information (their index value in the dataframe, + # track id, and ALON values and later on the SLON (sanitized ALON values). + TrackPt = namedtuple("TrackPt", "indices track alons alats") + + # named tuple holding "sanitized" longitudes + SanTrackPt = namedtuple("SanTrackPt", "indices track alons slons") + + # Keep track of the unique storm tracks by their storm_id + storm_track_dict = {} + + for cur_unique in self.unique_storm_ids: + idx_list = user_criteria_df.index[user_criteria_df['STORM_ID'] == cur_unique].tolist() + alons = [] + alats = [] + indices = [] + + for idx in idx_list: + alons.append(user_criteria_df.loc[idx, 'ALON']) + alats.append(user_criteria_df.loc[idx, 'ALAT']) + indices.append(idx) + + # create the track_pt tuple and add it to the storm track dictionary + track_pt = TrackPt(indices, cur_unique, alons, alats) + storm_track_dict[cur_unique] = track_pt + + # create a new dataframe to contain the sanitized lons (i.e. the original ALONs that have + # been cleaned up when crossing the International Date Line) + sanitized_df = user_criteria_df.copy(deep=True) + + # Now we have a dictionary that helps in aggregating the data based on + # storm tracks (via storm id) and will contain the "sanitized" lons + sanitized_storm_tracks = {} + for key in storm_track_dict: + # "Sanitize" the longitudes to shift the lons that cross the International Date Line. + # Create a new SanTrackPt named tuple and add that to a new dictionary + # that keeps track of the sanitized data based on the storm id + # sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) + sanitized_lons = self.sanitize_lonlist(storm_track_dict[key].alons) + sanitized_track_pt = SanTrackPt(storm_track_dict[key].indices, storm_track_dict[key].track, + storm_track_dict[key].alons, sanitized_lons) + sanitized_storm_tracks[key] = sanitized_track_pt + + # fill in the sanitized dataframe, sanitized_df + for key in sanitized_storm_tracks: + # now use the indices of the storm tracks to correctly assign the sanitized + # lons to the appropriate row in the dataframe to maintain the row ordering of + # the original dataframe + idx_list = sanitized_storm_tracks[key].indices + + for i, idx in enumerate(idx_list): + sanitized_df.loc[idx,'SLON'] = sanitized_storm_tracks[key].slons[i] + + # Set some useful values used for plotting. + # Set the IS_FIRST value to True if this is the first + # point in the storm track, False + # otherwise + if i == 0: + sanitized_df.loc[idx, 'IS_FIRST'] = True + else: + sanitized_df.loc[idx, 'IS_FIRST'] = False + + # Set the lead group to the character '0' if the valid hour is 0 or 12, + # or to the charcter '6' if the valid hour is 6 or 18. Set the marker + # to correspond to the valid hour: 'o' (open circle) for 0 or 12 valid hour, + # or '+' (small plus/cross) for 6 or 18. + if sanitized_df.loc[idx, 'VALID_HOUR'] == 0 or sanitized_df.loc[idx, 'VALID_HOUR'] == 12: + sanitized_df.loc[idx, 'LEAD_GROUP'] ='0' + sanitized_df.loc[idx, 'MARKER'] = self.circle_marker + elif sanitized_df.loc[idx, 'VALID_HOUR'] == 6 or sanitized_df.loc[idx, 'VALID_HOUR'] == 18: + sanitized_df.loc[idx, 'LEAD_GROUP'] = '6' + sanitized_df.loc[idx, 'MARKER'] = self.cross_marker + + # If the user has specified a region of interest rather than the + # global extent, subset the data even further to points that are within a bounding box. + if not self.is_global_extent: + self.logger.debug(f"Subset the data based on the region of interest.") + subset_by_region_df = self.subset_by_region(sanitized_df) + final_df = subset_by_region_df.copy(deep=True) + else: + final_df = sanitized_df.copy(deep=True) + + # Write output ASCII file (csv) summarizing the information extracted from the input + # which is used to generate the plot. + if self.gen_ascii: + self.logger.debug(f" output dir: {self.output_dir}") + util.mkdir_p(self.output_dir) + ascii_track_parts = [self.init_date, '.csv'] + ascii_track_output_name = ''.join(ascii_track_parts) + final_df_filename = os.path.join(self.output_dir, ascii_track_output_name) + + # Make sure that the dataframe is sorted by STORM_ID, INIT_YMD, INIT_HOUR, and LEAD + # to ensure that the line plot is connecting the points in the correct order. + final_sorted_df = final_df.sort_values(by=['STORM_ID', 'INIT_YMD', 'INIT_HOUR', 'LEAD'], ignore_index=True) + final_df.reset_index(drop=True,inplace=True) + final_sorted_df.to_csv(final_df_filename) + else: + # The user's specified directory isn't valid, log the error and exit. + self.logger.error("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory, check config file.") + sys.exit("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory.") + + return final_sorted_df + + + def create_plot(self): + """ + Create the plot, using Cartopy + + """ + + # Use PlateCarree projection for scatter plots + # and Geodetic projection for line plots. + cm_lon = self.central_longitude + prj = ccrs.PlateCarree(central_longitude=cm_lon) + ax = plt.axes(projection=prj) + + # for transforming the annotations (matplotlib to cartopy workaround from Stack Overflow) + transform = ccrs.PlateCarree()._as_mpl_transform(ax) + + # Add land, coastlines, and ocean + ax.add_feature(cfeature.LAND) + ax.coastlines() + ax.add_feature(cfeature.OCEAN) + + # keep map zoomed out to full world (ie global extent) if CYCLONE_PLOTTER_GLOBAL_PLOT is + # yes or True, otherwise use the lons and lats defined in the config file to + # create a polygon (rectangular box) defining the region of interest. + if self.is_global_extent: + ax.set_global() + self.logger.debug("Generating a plot of the global extent") + else: + self.logger.debug(f"Generating a plot of the user-defined extent:{self.west_lon}, {self.east_lon}, " + f"{self.south_lat}, {self.north_lat}") + extent_list = [self.west_lon, self.east_lon, self.south_lat, self.north_lat] + self.logger.debug(f"Setting map extent to: {self.west_lon}, {self.east_lon}, {self.south_lat}, {self.north_lat}") + # Bounding box will not necessarily be centered about the 180 degree longitude, so + # DO NOT explicitly set the central longitude. + ax.set_extent(extent_list, ccrs.PlateCarree()) + + # Add grid lines for longitude and latitude + gl = ax.gridlines(crs=ccrs.PlateCarree(), + draw_labels=True, linewidth=1, color='gray', + alpha=0.5, linestyle='--') + + gl.top_labels = False + gl.left_labels = True + gl.xlines = True + gl.xformatter = LONGITUDE_FORMATTER + gl.yformatter = LATITUDE_FORMATTER + gl.xlabel_style = {'size': 9, 'color': 'blue'} + gl.xlabel_style = {'color': 'black', 'weight': 'normal'} + + # Plot title + plt.title(self.title + "\nFor forecast with initial time = " + + self.init_date) + + # Optional: Create the NCAR watermark with a timestamp + # This will appear in the bottom right corner of the plot, below + # the x-axis. NOTE: The timestamp is in the user's local time zone + # and not in UTC time. + if self.add_watermark: + ts = time.time() + st = datetime.datetime.fromtimestamp(ts).strftime( + '%Y-%m-%d %H:%M:%S') + watermark = 'DTC METplus\nplot created at: ' + st + plt.text(60, -130, watermark, fontsize=5, alpha=0.25) + + # Make sure the output directory exists, and create it if it doesn't. + util.mkdir_p(self.output_dir) + + # get the points for the scatter plots (and the relevant information for annotations, etc.) + points_list = self.get_plot_points() + + # Legend labels + lead_group_0_legend = "Indicates a position at 00 or 12 UTC" + lead_group_6_legend = "Indicates a position at 06 or 18 UTC" + + # to be consistent with the NOAA website, use red for annotations, markers, and lines. + pt_color = 'red' + cross_marker_size = self.cross_marker_size + circle_marker_size = self.circle_marker_size + + # Get all the lat and lon (i.e. x and y) points for the '+' and 'o' marker types + # to be used in generating the scatter plots (one for the 0/12 hr and one for the 6/18 hr lead + # groups). Also collect ALL the lons and lats, which will be used to generate the + # line plot (the last plot that goes on top of all the scatter plots). + cross_lons = [] + cross_lats = [] + cross_annotations = [] + circle_lons = [] + circle_lats = [] + circle_annotations = [] + + for idx,pt in enumerate(points_list): + if pt.marker == self.cross_marker: + cross_lons.append(pt.lon) + cross_lats.append(pt.lat) + cross_annotations.append(pt.annotation) + # cross_marker = pt.marker + elif pt.marker == self.circle_marker: + circle_lons.append(pt.lon) + circle_lats.append(pt.lat) + circle_annotations.append(pt.annotation) + # circle_marker = pt.marker + + # Now generate the scatter plots for the lead group 0/12 hr ('+' marker) and the + # lead group 6/18 hr ('.' marker). + plt.scatter(circle_lons, circle_lats, s=self.circle_marker_size, c=pt_color, + marker=self.circle_marker, zorder=2, label=lead_group_0_legend, transform=ccrs.PlateCarree()) + plt.scatter(cross_lons, cross_lats, s=self.cross_marker_size, c=pt_color, + marker=self.cross_marker, zorder=2, label=lead_group_6_legend, transform=ccrs.PlateCarree()) + + # annotations for the scatter plots + counter = 0 + for x,y in zip(circle_lons, circle_lats): + plt.annotate(circle_annotations[counter], (x,y+1), xycoords=transform, color=pt_color, + fontsize=self.annotation_font_size) + counter += 1 + + counter = 0 + for x, y in zip(cross_lons, cross_lats): + plt.annotate(cross_annotations[counter], (x, y + 1), xycoords=transform, color=pt_color, + fontsize=self.annotation_font_size) + counter += 1 + + # Dummy point to add the additional label explaining the labelling of the first + # point in the storm track + plt.scatter(0, 0, zorder=2, marker=None, c='', + label="Date (dd/hhz) is the first " + + "time storm was able to be tracked in model") + + # Settings for the legend box location. + ax.legend(loc='lower left', bbox_to_anchor=(0, -0.4), + fancybox=True, shadow=True, scatterpoints=1, + prop={'size':self.legend_font_size}) + + # Generate the line plot + # First collect all the lats and lons for each storm track. Then for each storm track, + # generate a line plot. + pts_by_track_dict = self.get_points_by_track() + + for key in pts_by_track_dict: + lons = [] + lats = [] + for idx, pt in enumerate(pts_by_track_dict[key]): + lons.append(pt.lon) + lats.append(pt.lat) + + # Create the line plot for the current storm track, use the Geodetic coordinate reference system + # to correctly connect adjacent points that have been sanitized and cross the + # International Date line or the Prime Meridian. + plt.plot(lons, lats, linestyle='-', color=pt_color, linewidth=.4, transform=ccrs.Geodetic(), zorder=3) + + # Write the plot to the output directory + out_filename_parts = [self.init_date, '.png'] + output_plot_name = ''.join(out_filename_parts) + plot_filename = os.path.join(self.output_dir, output_plot_name) + if self.resolution_dpi > 0: + plt.savefig(plot_filename, dpi=self.resolution_dpi) + else: + # use Matplotlib's default if no resolution is set in config file + plt.savefig(plot_filename) + + + def get_plot_points(self): + """ + Get the lon and lat points to be plotted, along with any other plotting-relevant + information like the marker, whether this is a first point (to be used in + annotating the first point using the valid day date and valid hour), etc. + + :return: A list of named tuples that represent the points to plot with corresponding + plotting information + """ + + # Create a named tuple to store the point information + PlotPt = namedtuple("PlotPt", "storm_id lon lat is_first marker valid_dd valid_hour annotation") + + points_list = [] + storm_id = self.sanitized_df['STORM_ID'] + lons = self.sanitized_df['SLON'] + lats = self.sanitized_df['ALAT'] + is_first_list = self.sanitized_df['IS_FIRST'] + marker_list = self.sanitized_df['MARKER'] + valid_dd_list = self.sanitized_df['VALID_DD'] + valid_hour_list = self.sanitized_df['VALID_HOUR'] + annotation_list = [] + + for idx, cur_lon in enumerate(lons): + if is_first_list[idx] is True: + annotation = str(valid_dd_list[idx]).zfill(2) + '/' + \ + str(valid_hour_list[idx]).zfill(2) + 'z' + else: + annotation = None + + annotation_list.append(annotation) + cur_pt = PlotPt(storm_id, lons[idx], lats[idx], is_first_list[idx], marker_list[idx], + valid_dd_list[idx], valid_hour_list[idx], annotation) + points_list.append(cur_pt) + + return points_list + + + def get_points_by_track(self): + """ + Get all the lats and lons for each storm track. Used to generate the line + plot of the storm tracks. + + Args: + + :return: + points_by_track: Points aggregated by storm track. + Returns a dictionary where the key is the storm_id + and values are the points (lon,lat) stored in a named tuple + """ + track_dict = {} + LonLat = namedtuple("LonLat", "lon lat") + for cur_unique in self.unique_storm_ids: + # retrieve the ALAT and ALON values that correspond to the rows for a unique storm id. + # i.e. Get the index value(s) corresponding to this unique storm id + idx_list = self.sanitized_df.index[self.sanitized_df['STORM_ID'] == cur_unique].tolist() + sanitized_lons_and_lats = [] + indices = [] + for idx in idx_list: + cur_lonlat = LonLat(self.sanitized_df.loc[idx, 'SLON'], self.sanitized_df.loc[idx, 'ALAT']) + sanitized_lons_and_lats.append(cur_lonlat) + indices.append(idx) + + # update the track dictionary + track_dict[cur_unique] = sanitized_lons_and_lats + + return track_dict + + + def subset_by_region(self, sanitized_df): + """ + Args: + @param: sanitized_df the pandas dataframe containing + the "sanitized" longitudes and other useful + plotting information + + Returns: + :return: + """ + self.logger.debug("Subsetting by region...") + + # Copy the sanitized_df dataframe + sanitized_by_region_df = sanitized_df.copy(deep=True) + + # Iterate over ALL the rows and if any point is within the polygon, + # save it's index so we can create a new dataframe with just the + # relevant data. + for index, row in sanitized_by_region_df.iterrows(): + if (self.west_lon <= row['ALON'] <= self.east_lon) and (self.south_lat <= row['ALAT'] <= self.north_lat): + sanitized_by_region_df.loc[index,'INSIDE'] = True + else: + sanitized_by_region_df.loc[index,'INSIDE'] = False + + # Now filter the input dataframe based on the whether points are inside + # the specified boundaries. + masked = sanitized_by_region_df[sanitized_by_region_df['INSIDE'] == True] + masked.reset_index(drop=True,inplace=True) + + if len(masked) == 0: + sys.exit("No data in region specified, please check your lon and lat values in the config file.") + + return masked + + + @staticmethod + def sanitize_lonlist(lon_list): + """ + Solution from Stack Overflow for "sanitizing" longitudes that cross the International Date Line + https://stackoverflow.com/questions/67730660/plotting-line-across-international-dateline-with-cartopy + + Args: + @param lon_list: A list of longitudes (float) that correspond to a storm track + + Returns: + new_list: a list of "sanitized" lons that are "corrected" for crossing the + International Date Line + """ + + new_list = [] + oldval = 0 + # used to compare adjacent longitudes in a storm track + treshold = 10 + for ix, ea in enumerate(lon_list): + diff = oldval - ea + if (ix > 0): + if (diff > treshold): + ea = ea + 360 + oldval = ea + new_list.append(ea) + + return new_list From 8d444e2651820ed8ac2c22407a65397557903888 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 3 Nov 2021 16:01:41 -0600 Subject: [PATCH 03/43] return None from function instead of exiting so that METplus clean up functionality will still run --- metplus/wrappers/cyclone_plotter_wrapper.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index 7a5870e4b5..9c012e562e 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -188,6 +188,8 @@ def run_all_times(self): """ self.sanitized_df = self.retrieve_data() + if not self.sanitized_df: + return None self.create_plot() @@ -218,7 +220,7 @@ def retrieve_data(self): # check for empty dataframe, set error message and exit if combined.empty: self.logger.error("No data found in specified files. Please check your config file settings.") - sys.exit("No data found.") + return None # if there are any NaN values in the ALAT, ALON, STORM_ID, LEAD, INIT, AMODEL, or VALID column, # drop that row of data (axis=0). We need all these columns to contain valid data in order @@ -370,7 +372,7 @@ def retrieve_data(self): else: # The user's specified directory isn't valid, log the error and exit. self.logger.error("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory, check config file.") - sys.exit("CYCLONE_PLOTTER_INPUT_DIR isn't a valid directory.") + return None return final_sorted_df From f9480e8ebfb3719e0151df724afacee8ced122df Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 3 Nov 2021 16:02:17 -0600 Subject: [PATCH 04/43] fixed indentation --- metplus/wrappers/cyclone_plotter_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index 9c012e562e..05df085166 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -211,7 +211,7 @@ def retrieve_data(self): self.input_data) # Get the list of all files (full file path) in this directory all_input_files = util.get_files(self.input_data, ".*.tcst", - self.logger) + self.logger) # read each file into pandas then concatenate them together df_list = [pd.read_csv(file, delim_whitespace=True) for file in all_input_files] From dbdec0b35de01ce3ee4a4bce3ba7b1e5c330d4a3 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 3 Nov 2021 16:03:53 -0600 Subject: [PATCH 05/43] fixed indentation - for loop should not be nested inside other for loop --- metplus/wrappers/cyclone_plotter_wrapper.py | 58 ++++++++++----------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index 05df085166..8a8e7234cd 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -316,35 +316,35 @@ def retrieve_data(self): storm_track_dict[key].alons, sanitized_lons) sanitized_storm_tracks[key] = sanitized_track_pt - # fill in the sanitized dataframe, sanitized_df - for key in sanitized_storm_tracks: - # now use the indices of the storm tracks to correctly assign the sanitized - # lons to the appropriate row in the dataframe to maintain the row ordering of - # the original dataframe - idx_list = sanitized_storm_tracks[key].indices - - for i, idx in enumerate(idx_list): - sanitized_df.loc[idx,'SLON'] = sanitized_storm_tracks[key].slons[i] - - # Set some useful values used for plotting. - # Set the IS_FIRST value to True if this is the first - # point in the storm track, False - # otherwise - if i == 0: - sanitized_df.loc[idx, 'IS_FIRST'] = True - else: - sanitized_df.loc[idx, 'IS_FIRST'] = False - - # Set the lead group to the character '0' if the valid hour is 0 or 12, - # or to the charcter '6' if the valid hour is 6 or 18. Set the marker - # to correspond to the valid hour: 'o' (open circle) for 0 or 12 valid hour, - # or '+' (small plus/cross) for 6 or 18. - if sanitized_df.loc[idx, 'VALID_HOUR'] == 0 or sanitized_df.loc[idx, 'VALID_HOUR'] == 12: - sanitized_df.loc[idx, 'LEAD_GROUP'] ='0' - sanitized_df.loc[idx, 'MARKER'] = self.circle_marker - elif sanitized_df.loc[idx, 'VALID_HOUR'] == 6 or sanitized_df.loc[idx, 'VALID_HOUR'] == 18: - sanitized_df.loc[idx, 'LEAD_GROUP'] = '6' - sanitized_df.loc[idx, 'MARKER'] = self.cross_marker + # fill in the sanitized dataframe, sanitized_df + for key in sanitized_storm_tracks: + # now use the indices of the storm tracks to correctly assign the sanitized + # lons to the appropriate row in the dataframe to maintain the row ordering of + # the original dataframe + idx_list = sanitized_storm_tracks[key].indices + + for i, idx in enumerate(idx_list): + sanitized_df.loc[idx,'SLON'] = sanitized_storm_tracks[key].slons[i] + + # Set some useful values used for plotting. + # Set the IS_FIRST value to True if this is the first + # point in the storm track, False + # otherwise + if i == 0: + sanitized_df.loc[idx, 'IS_FIRST'] = True + else: + sanitized_df.loc[idx, 'IS_FIRST'] = False + + # Set the lead group to the character '0' if the valid hour is 0 or 12, + # or to the charcter '6' if the valid hour is 6 or 18. Set the marker + # to correspond to the valid hour: 'o' (open circle) for 0 or 12 valid hour, + # or '+' (small plus/cross) for 6 or 18. + if sanitized_df.loc[idx, 'VALID_HOUR'] == 0 or sanitized_df.loc[idx, 'VALID_HOUR'] == 12: + sanitized_df.loc[idx, 'LEAD_GROUP'] ='0' + sanitized_df.loc[idx, 'MARKER'] = self.circle_marker + elif sanitized_df.loc[idx, 'VALID_HOUR'] == 6 or sanitized_df.loc[idx, 'VALID_HOUR'] == 18: + sanitized_df.loc[idx, 'LEAD_GROUP'] = '6' + sanitized_df.loc[idx, 'MARKER'] = self.cross_marker # If the user has specified a region of interest rather than the # global extent, subset the data even further to points that are within a bounding box. From 4ec50af4c393d3f85985ab515722e0ce14df3524 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 3 Nov 2021 16:34:27 -0600 Subject: [PATCH 06/43] fixed check for failure in retrieve_data function --- metplus/wrappers/cyclone_plotter_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index 8a8e7234cd..b05e76ded2 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -188,7 +188,7 @@ def run_all_times(self): """ self.sanitized_df = self.retrieve_data() - if not self.sanitized_df: + if self.sanitized_df is None: return None self.create_plot() From 54f368a991285a9d5afd99a3f48aa88d3ea3d0c8 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 4 Nov 2021 10:09:39 -0600 Subject: [PATCH 07/43] feature 1223 error if file not found (#1238) --- metplus/util/met_util.py | 2 +- metplus/wrappers/tcmpr_plotter_wrapper.py | 2 +- .../read_ascii_storm.py | 165 +++++++++--------- 3 files changed, 83 insertions(+), 86 deletions(-) diff --git a/metplus/util/met_util.py b/metplus/util/met_util.py index 7ebd0b3862..4e5118b6a3 100644 --- a/metplus/util/met_util.py +++ b/metplus/util/met_util.py @@ -1516,7 +1516,7 @@ def get_files(filedir, filename_regex, logger=None): file_paths.append(filepath) else: continue - return file_paths + return sorted(file_paths) def prune_empty(output_dir, logger): """! Start from the output_dir, and recursively check diff --git a/metplus/wrappers/tcmpr_plotter_wrapper.py b/metplus/wrappers/tcmpr_plotter_wrapper.py index 4f574ec606..371b1a8417 100755 --- a/metplus/wrappers/tcmpr_plotter_wrapper.py +++ b/metplus/wrappers/tcmpr_plotter_wrapper.py @@ -283,7 +283,7 @@ def get_input_files(self): input_files = util.get_files(input_data, ".*.tcst") self.logger.debug(f"Number of files: {len(input_files)}") - return sorted(input_files) + return input_files def format_arg_string(self): arg_list = [] diff --git a/parm/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect/read_ascii_storm.py b/parm/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect/read_ascii_storm.py index a46a141ff2..1340e3b274 100644 --- a/parm/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect/read_ascii_storm.py +++ b/parm/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect/read_ascii_storm.py @@ -2,92 +2,89 @@ import pandas as pd import os import sys -import ntpath -######################################################################## +print(f'Python Script: {sys.argv[0]}') + +# input file specified on the command line +# load the data into the numpy array -print('Python Script:\t', sys.argv[0]) - - ## - ## input file specified on the command line - ## load the data into the numpy array - ## - -if len(sys.argv) == 2: - # Read the input file as the first argument - input_file = os.path.expandvars(sys.argv[1]) - try: - print("Input File:\t" + repr(input_file)) - - # Read and format the input 11-column observations: - # (1) string: Message_Type - # (2) string: Station_ID - # (3) string: Valid_Time(YYYYMMDD_HHMMSS) - # (4) numeric: Lat(Deg North) - # (5) numeric: Lon(Deg East) - # (6) numeric: Elevation(msl) - # (7) string: Var_Name(or GRIB_Code) - # (8) numeric: Level - # (9) numeric: Height(msl or agl) - # (10) string: QC_String - # (11) numeric: Observation_Value - - column_names = ["Message_Type","Station_ID","Valid_Time","Lat","Lon","Elevation","Var_Name","Level","Height","QC_String","Observation_Value"] - - # Create a blank dataframe based on the 11 column standard - point_frame = pd.DataFrame(columns=column_names,dtype='str') - - #Read in the Storm report, 8 columns not matching the 11 column standard - temp_data = pd.read_csv(input_file,names=['Time', 'Fscale', 'Location', 'County','Stat','Lat', 'Lon', 'Comment'], dtype=str ,skiprows=1) - - #Strip out any rows in the middle that are actually header rows - #Allows for concatenating storm reports together - temp_data = temp_data[temp_data["Time"] != "Time"] - - #Change some columns to floats and ints - temp_data[["Lat","Lon"]] = temp_data[["Lat","Lon"]].apply(pd.to_numeric) - - #Assign approprite columns to point_frame leaving missing as empty strings - point_frame["Lat"] = temp_data["Lat"] - point_frame["Lon"] = temp_data["Lon"] - #point_frame["Station_ID"] = temp_data["County"] - point_frame["Station_ID"] = "NA" - point_frame["Var_Name"] = "Fscale" - point_frame["Message_Type"] = "StormReport" - - #Assign 0.0 values to numeric point_frame columns that we don't have in the csv file - point_frame["Elevation"] = 0.0 - point_frame["Level"] = 0.0 - point_frame["Height"] = 0.0 - - #Change Comments into a "QC" string Tornado=1, Hail=2, Wind=3, Other=4 - point_frame["QC_String"] = "4" - mask = temp_data["Comment"].str.contains('TORNADO') - point_frame.loc[mask,"QC_String"] = "1" - mask = temp_data["Comment"].str.contains('HAIL') - point_frame.loc[mask,"QC_String"] = "2" - mask = temp_data["Comment"].str.contains('WIND') - point_frame.loc[mask,"QC_String"] = "3" - - #Time is HHMM in the csv file so we need to use a piece of the filename and - #this value to create a valid date string - file_without_path = ntpath.basename(input_file) - year_month_day = "20"+file_without_path[0:6] - point_frame["Valid_Time"] = year_month_day+"_"+temp_data["Time"]+"00" - - #Currently we are only interested in the fact that we have a report at that locaton - #and not its actual value so all values are 1.0 - point_frame["Observation_Value"] = 1.0 - - #Ascii2nc wants the final values in a list - point_data = point_frame.values.tolist() - - print("Data Length:\t" + repr(len(point_data))) - print("Data Type:\t" + repr(type(point_data))) - except NameError: - print("Can't find the input file") -else: - print("ERROR: read_ascii_storm.py -> Must specify exactly one input file.") +if len(sys.argv) < 2: + script_name = os.path.basename(sys.argv[0]) + print(f"ERROR: {script_name} -> Must specify exactly one input file.") sys.exit(1) +# Read the input file as the first argument +input_file = os.path.expandvars(sys.argv[1]) +print(f'Input File: {input_file}') + +if not os.path.exists(input_file): + print("ERROR: Could not find input file") + sys.exit(2) + +# Read and format the input 11-column observations +COLUMN_NAMES = ( + "Message_Type", # (1) string + "Station_ID", # (2) string + "Valid_Time", # (3) string (YYYYMMDD_HHMMSS) + "Lat", # (4) numeric (Deg North) + "Lon", # (5) numeric (Deg East) + "Elevation", # (6) numeric (msl) + "Var_Name", # (7) string (or GRIB_Code) + "Level", # (8) numeric + "Height", # (9) numeric (msl or agl) + "QC_String", # (10) string + "Observation_Value" # (11) numeric +) + +# Create a blank dataframe based on the 11 column standard +point_frame = pd.DataFrame(columns=COLUMN_NAMES,dtype='str') + +#Read in the Storm report, 8 columns not matching the 11 column standard +temp_data = pd.read_csv(input_file,names=['Time', 'Fscale', 'Location', 'County','Stat','Lat', 'Lon', 'Comment'], dtype=str ,skiprows=1) + +#Strip out any rows in the middle that are actually header rows +#Allows for concatenating storm reports together +temp_data = temp_data[temp_data["Time"] != "Time"] + +#Change some columns to floats and ints +temp_data[["Lat","Lon"]] = temp_data[["Lat","Lon"]].apply(pd.to_numeric) + +#Assign approprite columns to point_frame leaving missing as empty strings +point_frame["Lat"] = temp_data["Lat"] +point_frame["Lon"] = temp_data["Lon"] +#point_frame["Station_ID"] = temp_data["County"] +point_frame["Station_ID"] = "NA" +point_frame["Var_Name"] = "Fscale" +point_frame["Message_Type"] = "StormReport" + +#Assign 0.0 values to numeric point_frame columns that we don't have in the csv file +point_frame["Elevation"] = 0.0 +point_frame["Level"] = 0.0 +point_frame["Height"] = 0.0 + +#Change Comments into a "QC" string Tornado=1, Hail=2, Wind=3, Other=4 +point_frame["QC_String"] = "4" +mask = temp_data["Comment"].str.contains('TORNADO') +point_frame.loc[mask,"QC_String"] = "1" +mask = temp_data["Comment"].str.contains('HAIL') +point_frame.loc[mask,"QC_String"] = "2" +mask = temp_data["Comment"].str.contains('WIND') +point_frame.loc[mask,"QC_String"] = "3" + +#Time is HHMM in the csv file so we need to use a piece of the filename and +#this value to create a valid date string +file_without_path = os.path.basename(input_file) +year_month_day = "20"+file_without_path[0:6] +point_frame["Valid_Time"] = year_month_day+"_"+temp_data["Time"]+"00" + +#Currently we are only interested in the fact that we have a report at that locaton +#and not its actual value so all values are 1.0 +point_frame["Observation_Value"] = 1.0 + +#Ascii2nc wants the final values in a list +point_data = point_frame.values.tolist() + +print("Data Length:\t" + repr(len(point_data))) +print("Data Type:\t" + repr(type(point_data))) + ######################################################################## From 7094e0a613d354d894119900327837b6da143644 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 11 Nov 2021 16:29:32 -0700 Subject: [PATCH 08/43] feature 1252 allow dictionary value for time_summary.width (#1253) --- internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py | 8 ++++++++ metplus/wrappers/command_builder.py | 9 +++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py b/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py index 641c4d3599..8db28911a4 100644 --- a/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py +++ b/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py @@ -88,6 +88,14 @@ def ascii2nc_wrapper(metplus_config, config_path=None, config_overrides=None): 'grib_code = [11, 204, 211];obs_var = [];' 'type = ["min", "max", "range", "mean", "stdev", "median", "p80"];' 'vld_freq = 0;vld_thresh = 0.0;}')}), + # width as dictionary + ({'ASCII2NC_TIME_SUMMARY_WIDTH': '{ beg = -21600; end = 0; }'}, + {'METPLUS_TIME_SUMMARY_DICT': + ('time_summary = {flag = FALSE;raw_data = FALSE;beg = "000000";' + 'end = "235959";step = 300;width = { beg = -21600; end = 0; };' + 'grib_code = [11, 204, 211];obs_var = [];' + 'type = ["min", "max", "range", "mean", "stdev", "median", "p80"];' + 'vld_freq = 0;vld_thresh = 0.0;}')}), ({'ASCII2NC_TIME_SUMMARY_GRIB_CODES': '12, 203, 212'}, {'METPLUS_TIME_SUMMARY_DICT': diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 64bdc9c255..9e22aa6642 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -2012,10 +2012,11 @@ def handle_time_summary_dict(self, c_dict, remove_bracket_list=None): 'step', 'TIME_SUMMARY_STEP') - self.set_met_config_int(tmp_dict, - f'{app}_TIME_SUMMARY_WIDTH', - 'width', - 'TIME_SUMMARY_WIDTH') + self.set_met_config_string(tmp_dict, + f'{app}_TIME_SUMMARY_WIDTH', + 'width', + 'TIME_SUMMARY_WIDTH', + remove_quotes=True) self.set_met_config_list(tmp_dict, [f'{app}_TIME_SUMMARY_GRIB_CODES', From b021341ba7b6260057ba34d12b3b64b87374791a Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 15 Nov 2021 10:37:36 -0700 Subject: [PATCH 09/43] feature 1213 obs_quality_inc/exc (#1260) --- docs/Users_Guide/glossary.rst | 22 +++++++++- docs/Users_Guide/wrappers.rst | 44 +++++++++++++++++-- .../test_ensemble_stat_wrapper.py | 4 ++ .../point_stat/test_point_stat_wrapper.py | 6 ++- metplus/wrappers/ensemble_stat_wrapper.py | 15 +++++++ metplus/wrappers/point_stat_wrapper.py | 13 ++++-- parm/met_config/EnsembleStatConfig_wrapped | 8 +++- parm/met_config/PointStatConfig_wrapped | 9 +++- .../EnsembleStat/EnsembleStat.conf | 3 ++ .../met_tool_wrapper/PointStat/PointStat.conf | 4 +- 10 files changed, 114 insertions(+), 14 deletions(-) diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index c924aded30..3484ae5beb 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -6661,11 +6661,19 @@ METplus Configuration Glossary | *Used by:* MODE - POINT_STAT_OBS_QUALITY - Specify the value for 'obs_quality' in the MET configuration file for PointStat. + POINT_STAT_OBS_QUALITY_INC + Specify the value for 'obs_quality_inc' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_OBS_QUALITY_EXC + Specify the value for 'obs_quality_exc' in the MET configuration file for PointStat. | *Used by:* PointStat + POINT_STAT_OBS_QUALITY + .. warning:: **DEPRECATED:** Please use :term:`POINT_STAT_OBS_QUALITY_INC` instead. + POINT_STAT_OUTPUT_FLAG_FHO Specify the value for 'output_flag.fho' in the MET configuration file for PointStat. @@ -8257,3 +8265,13 @@ METplus Configuration Glossary Specify the value for 'ens.file_type' in the MET configuration file for GenEnsProd. | *Used by:* GenEnsProd + + ENSEMBLE_STAT_OBS_QUALITY_INC + Specify the value for 'obs_quality_inc' in the MET configuration file for EnsembleStat. + + | *Used by:* EnsembleStat + + ENSEMBLE_STAT_OBS_QUALITY_EXC + Specify the value for 'obs_quality_exc' in the MET configuration file for EnsembleStat. + + | *Used by:* EnsembleStat diff --git a/docs/Users_Guide/wrappers.rst b/docs/Users_Guide/wrappers.rst index 8b07448f8a..18fc97d57e 100644 --- a/docs/Users_Guide/wrappers.rst +++ b/docs/Users_Guide/wrappers.rst @@ -274,6 +274,8 @@ METplus Configuration | :term:`ENSEMBLE_STAT_ENSEMBLE_FLAG_NMEP` | :term:`ENSEMBLE_STAT_ENSEMBLE_FLAG_RANK` | :term:`ENSEMBLE_STAT_ENSEMBLE_FLAG_WEIGHT` +| :term:`ENSEMBLE_STAT_OBS_QUALITY_INC` +| :term:`ENSEMBLE_STAT_OBS_QUALITY_EXC` | :term:`ENSEMBLE_STAT_MET_CONFIG_OVERRIDES` | :term:`ENSEMBLE_STAT_VERIFICATION_MASK_TEMPLATE` (optional) | :term:`ENS_VAR_NAME` (optional) @@ -829,6 +831,28 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`ENSEMBLE_STAT_OUTPUT_PREFIX` - output_prefix +**${METPLUS_OBS_QUALITY_INC}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`ENSEMBLE_STAT_OBS_QUALITY_INC` + - obs_quality_inc + +**${METPLUS_OBS_QUALITY_EXC}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`ENSEMBLE_STAT_OBS_QUALITY_EXC` + - obs_quality_exc + **${METPLUS_MET_CONFIG_OVERRIDES}** .. list-table:: @@ -4854,7 +4878,8 @@ Configuration | :term:`POINT_STAT_CLIMO_CDF_BINS` | :term:`POINT_STAT_CLIMO_CDF_CENTER_BINS` | :term:`POINT_STAT_CLIMO_CDF_WRITE_BINS` -| :term:`POINT_STAT_OBS_QUALITY` +| :term:`POINT_STAT_OBS_QUALITY_INC` +| :term:`POINT_STAT_OBS_QUALITY_EXC` | :term:`POINT_STAT_OUTPUT_FLAG_FHO` | :term:`POINT_STAT_OUTPUT_FLAG_CTC` | :term:`POINT_STAT_OUTPUT_FLAG_CTS` @@ -5196,7 +5221,18 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`POINT_STAT_CLIMO_CDF_WRITE_BINS` - climo_cdf.write_bins -**${METPLUS_OBS_QUALITY}** +**${METPLUS_OBS_QUALITY_INC}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`POINT_STAT_OBS_QUALITY_INC` + - obs_quality_inc + +**${METPLUS_OBS_QUALITY_EXC}** .. list-table:: :widths: 5 5 @@ -5204,8 +5240,8 @@ see :ref:`How METplus controls MET config file settings`. * - METplus Config(s) - MET Config File - * - :term:`POINT_STAT_OBS_QUALITY` - - obs_quality + * - :term:`POINT_STAT_OBS_QUALITY_EXC` + - obs_quality_exc **${METPLUS_OUTPUT_FLAG_DICT}** diff --git a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py index d0f525654f..750fe994af 100644 --- a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py +++ b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py @@ -540,6 +540,10 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, 'type = [{method = GAUSSIAN;width = 1;}];}' ) }), + ({'ENSEMBLE_STAT_OBS_QUALITY_INC': '2,3,4', }, + {'METPLUS_OBS_QUALITY_INC': 'obs_quality_inc = ["2", "3", "4"];'}), + ({'ENSEMBLE_STAT_OBS_QUALITY_EXC': '5,6,7', }, + {'METPLUS_OBS_QUALITY_EXC': 'obs_quality_exc = ["5", "6", "7"];'}), ] ) diff --git a/internal_tests/pytests/point_stat/test_point_stat_wrapper.py b/internal_tests/pytests/point_stat/test_point_stat_wrapper.py index 7155d1d1b8..e8c2302f35 100755 --- a/internal_tests/pytests/point_stat/test_point_stat_wrapper.py +++ b/internal_tests/pytests/point_stat/test_point_stat_wrapper.py @@ -178,8 +178,12 @@ def test_met_dictionary_in_var_options(metplus_config): { 'METPLUS_CLIMO_CDF_DICT': 'climo_cdf = {cdf_bins = 1.0;center_bins = TRUE;write_bins = FALSE;}'}), + ({'POINT_STAT_OBS_QUALITY_INC': '2,3,4', }, + {'METPLUS_OBS_QUALITY_INC': 'obs_quality_inc = ["2", "3", "4"];'}), + ({'POINT_STAT_OBS_QUALITY_EXC': '5,6,7', }, + {'METPLUS_OBS_QUALITY_EXC': 'obs_quality_exc = ["5", "6", "7"];'}), ({'POINT_STAT_OBS_QUALITY': '1, 2, 3', }, - {'METPLUS_OBS_QUALITY': 'obs_quality = ["1", "2", "3"];'}), + {'METPLUS_OBS_QUALITY_INC': 'obs_quality_inc = ["1", "2", "3"];'}), ({'POINT_STAT_OUTPUT_FLAG_FHO': 'BOTH', }, {'METPLUS_OUTPUT_FLAG_DICT': 'output_flag = {fho = BOTH;}'}), diff --git a/metplus/wrappers/ensemble_stat_wrapper.py b/metplus/wrappers/ensemble_stat_wrapper.py index e171987be8..652170ffbe 100755 --- a/metplus/wrappers/ensemble_stat_wrapper.py +++ b/metplus/wrappers/ensemble_stat_wrapper.py @@ -61,6 +61,8 @@ class EnsembleStatWrapper(CompareGriddedWrapper): 'METPLUS_OUTPUT_FLAG_DICT', 'METPLUS_ENSEMBLE_FLAG_DICT', 'METPLUS_OUTPUT_PREFIX', + 'METPLUS_OBS_QUALITY_INC', + 'METPLUS_OBS_QUALITY_EXC', ] # handle deprecated env vars used pre v4.0.0 @@ -299,6 +301,19 @@ def create_c_dict(self): c_dict['MASK_POLY_TEMPLATE'] = self.read_mask_poly() + self.add_met_config( + name='obs_quality_inc', + data_type='list', + metplus_configs=['ENSEMBLE_STAT_OBS_QUALITY_INC', + 'ENSEMBLE_STAT_OBS_QUALITY_INCLUDE'] + ) + self.add_met_config( + name='obs_quality_exc', + data_type='list', + metplus_configs=['ENSEMBLE_STAT_OBS_QUALITY_EXC', + 'ENSEMBLE_STAT_OBS_QUALITY_EXCLUDE'] + ) + # old method of setting MET config values c_dict['ENS_THRESH'] = ( self.config.getstr('config', 'ENSEMBLE_STAT_ENS_THRESH', '1.0') diff --git a/metplus/wrappers/point_stat_wrapper.py b/metplus/wrappers/point_stat_wrapper.py index fb1887a356..a21df7caad 100755 --- a/metplus/wrappers/point_stat_wrapper.py +++ b/metplus/wrappers/point_stat_wrapper.py @@ -33,7 +33,8 @@ class PointStatWrapper(CompareGriddedWrapper): 'METPLUS_MASK_SID', 'METPLUS_OUTPUT_PREFIX', 'METPLUS_CLIMO_CDF_DICT', - 'METPLUS_OBS_QUALITY', + 'METPLUS_OBS_QUALITY_INC', + 'METPLUS_OBS_QUALITY_EXC', 'METPLUS_OUTPUT_FLAG_DICT', 'METPLUS_INTERP_DICT', 'METPLUS_CLIMO_MEAN_DICT', @@ -194,9 +195,15 @@ def create_c_dict(self): False) ) - self.add_met_config(name='obs_quality', + self.add_met_config(name='obs_quality_inc', data_type='list', - metplus_configs=['POINT_STAT_OBS_QUALITY']) + metplus_configs=['POINT_STAT_OBS_QUALITY_INC', + 'POINT_STAT_OBS_QUALITY_INCLUDE', + 'POINT_STAT_OBS_QUALITY']) + self.add_met_config(name='obs_quality_exc', + data_type='list', + metplus_configs=['POINT_STAT_OBS_QUALITY_EXC', + 'POINT_STAT_OBS_QUALITY_EXCLUDE']) self.handle_flags('output') diff --git a/parm/met_config/EnsembleStatConfig_wrapped b/parm/met_config/EnsembleStatConfig_wrapped index 5331c5583c..e9ef2d5667 100644 --- a/parm/met_config/EnsembleStatConfig_wrapped +++ b/parm/met_config/EnsembleStatConfig_wrapped @@ -95,7 +95,13 @@ obs = { ${METPLUS_MESSAGE_TYPE} sid_exc = []; obs_thresh = [ NA ]; -obs_quality = []; + +//obs_quality_inc = +${METPLUS_OBS_QUALITY_INC} + +//obs_quality_exc = +${METPLUS_OBS_QUALITY_EXC} + ${METPLUS_DUPLICATE_FLAG} obs_summary = NONE; obs_perc_value = 50; diff --git a/parm/met_config/PointStatConfig_wrapped b/parm/met_config/PointStatConfig_wrapped index 2a0d8d1907..2a654d6d23 100644 --- a/parm/met_config/PointStatConfig_wrapped +++ b/parm/met_config/PointStatConfig_wrapped @@ -63,8 +63,13 @@ obs = { // message_type = ${METPLUS_MESSAGE_TYPE} sid_exc = []; -//obs_quality = -${METPLUS_OBS_QUALITY} + +//obs_quality_inc = +${METPLUS_OBS_QUALITY_INC} + +//obs_quality_exc = +${METPLUS_OBS_QUALITY_EXC} + duplicate_flag = NONE; obs_summary = NONE; obs_perc_value = 50; diff --git a/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf b/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf index 261bff3325..c4c61ce8e7 100644 --- a/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf +++ b/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf @@ -169,6 +169,9 @@ ENSEMBLE_STAT_ENSEMBLE_FLAG_NMEP = FALSE ENSEMBLE_STAT_ENSEMBLE_FLAG_RANK = TRUE ENSEMBLE_STAT_ENSEMBLE_FLAG_WEIGHT = FALSE +#ENSEMBLE_STAT_OBS_QUALITY_INC = +#ENSEMBLE_STAT_OBS_QUALITY_EXC = + # Ensemble Variables and levels as specified in the ens field dictionary # of the MET configuration file. Specify as ENS_VARn_NAME, ENS_VARn_LEVELS, # (optional) ENS_VARn_OPTION diff --git a/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf b/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf index a290427772..36875e0a79 100644 --- a/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf +++ b/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf @@ -50,7 +50,9 @@ LOOP_ORDER = processes # or the value of the environment variable METPLUS_PARM_BASE if set POINT_STAT_CONFIG_FILE ={PARM_BASE}/met_config/PointStatConfig_wrapped -#POINT_STAT_OBS_QUALITY = 1, 2, 3 + +#POINT_STAT_OBS_QUALITY_INC = 1, 2, 3 +#POINT_STAT_OBS_QUALITY_EXC = POINT_STAT_CLIMO_MEAN_TIME_INTERP_METHOD = NEAREST #POINT_STAT_CLIMO_STDEV_TIME_INTERP_METHOD = From 9a6473a131ae64fa4b5606637b04d0284b48018e Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 15 Nov 2021 12:20:51 -0700 Subject: [PATCH 10/43] Feature 1203 ioda2nc (#1262) --- .github/parm/use_case_groups.json | 5 + docs/Contributors_Guide/create_wrapper.rst | 64 ++++- docs/Users_Guide/glossary.rst | 258 ++++++++++++++++- docs/Users_Guide/quicksearch.rst | 2 + docs/Users_Guide/wrappers.rst | 269 ++++++++++++++++++ .../met_tool_wrapper/IODA2NC/IODA2NC.py | 110 +++++++ .../met_tool_wrapper/IODA2NC/README.rst | 2 + .../gen_ens_prod/test_gen_ens_prod_wrapper.py | 54 ---- .../pytests/ioda2nc/test_ioda2nc_wrapper.py | 246 ++++++++++++++++ internal_tests/use_cases/all_use_cases.txt | 1 + metplus/util/doc_util.py | 1 + metplus/util/met_util.py | 2 +- metplus/wrappers/ascii2nc_wrapper.py | 10 +- metplus/wrappers/command_builder.py | 45 ++- metplus/wrappers/gen_ens_prod_wrapper.py | 4 - metplus/wrappers/ioda2nc_wrapper.py | 174 +++++++++++ metplus/wrappers/pb2nc_wrapper.py | 2 +- metplus/wrappers/runtime_freq_wrapper.py | 2 +- parm/met_config/Ascii2NcConfig_wrapped | 2 + parm/met_config/EnsembleStatConfig_wrapped | 2 + parm/met_config/GenEnsProdConfig_wrapped | 2 + parm/met_config/GridDiagConfig_wrapped | 2 + parm/met_config/GridStatConfig_wrapped | 4 +- parm/met_config/IODA2NCConfig_wrapped | 117 ++++++++ parm/met_config/MODEConfig_wrapped | 2 + parm/met_config/MTDConfig_wrapped | 2 + parm/met_config/PB2NCConfig_wrapped | 3 +- parm/met_config/PointStatConfig_wrapped | 3 +- parm/met_config/STATAnalysisConfig_wrapped | 4 +- parm/met_config/SeriesAnalysisConfig_wrapped | 4 +- parm/met_config/TCGenConfig_wrapped | 2 + parm/met_config/TCPairsConfig_wrapped | 2 + parm/met_config/TCRMWConfig_wrapped | 2 + parm/met_config/TCStatConfig_wrapped | 2 + .../met_tool_wrapper/IODA2NC/IODA2NC.conf | 83 ++++++ 35 files changed, 1409 insertions(+), 80 deletions(-) create mode 100644 docs/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.py create mode 100644 docs/use_cases/met_tool_wrapper/IODA2NC/README.rst create mode 100644 internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py create mode 100755 metplus/wrappers/ioda2nc_wrapper.py create mode 100644 parm/met_config/IODA2NCConfig_wrapped create mode 100644 parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 536774aac5..4cb2de818b 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -9,6 +9,11 @@ "index_list": "30-58", "run": false }, + { + "category": "met_tool_wrapper", + "index_list": "59", + "run": false + }, { "category": "air_quality_and_comp", "index_list": "0", diff --git a/docs/Contributors_Guide/create_wrapper.rst b/docs/Contributors_Guide/create_wrapper.rst index 63ffb66c0f..e051a04211 100644 --- a/docs/Contributors_Guide/create_wrapper.rst +++ b/docs/Contributors_Guide/create_wrapper.rst @@ -26,11 +26,13 @@ In metplus/util/doc_util.py, add entries to the LOWER_TO_WRAPPER_NAME dictionary so that the wrapper can be found in the PROCESS_LIST even if it is formatted differently. The key should be the wrapper name in all lower-case letters without any underscores. The value should be the class name -of the wrapper without the "Wrapper" suffix. Examples:: +of the wrapper without the "Wrapper" suffix. Add the new entry in the location +to preserve alphabetical order so it is easier for other developers to find +it. Examples:: - 'newtool': 'NewTool', 'ascii2nc': 'ASCII2NC', 'ensemblestat': 'EnsembleStat', + 'newtool': 'NewTool', The name of a tool can be formatted in different ways depending on the context. For example, the MET tool PCPCombine is written as Pcp-Combine in the MET @@ -59,6 +61,9 @@ Wrapper Components Open the wrapper file for editing the new class. +Naming +^^^^^^ + Rename the class to match the wrapper's class from the above sections. Most wrappers should be a sub-class of the CommandBuilder wrapper:: @@ -67,19 +72,28 @@ Most wrappers should be a sub-class of the CommandBuilder wrapper:: The text 'CommandBuilder' in parenthesis makes NewToolWrapper a subclass of CommandBuilder. +Find and replace can be used to rename all instances of the wrapper name in +the file. For example, to create IODA2NC wrapper from ASCII2NC, replace +**ascii2nc** with **ioda2nc** and **ASCII2NC** with **IODA2NC**. +To create EnsembleStat wrapper from GridStat, replace +**grid_stat** with **ensemble_stat** and +**GridStat** with **EnsembleStat**. + +Parent Class +^^^^^^^^^^^^ + If the new tool falls under one of the existing tool categories, then you can make the tool a subclass of one of those classes. This should only be done if the functions in the parent class are needed by the new wrapper. If you are unsure, then use CommandBuilder. -Refer to the :ref:`basic_components_of_wrappers` section of the Contributor's -Guide for more information on what should be added. - Init Function ^^^^^^^^^^^^^ Modify the init function to initialize NewTool from its base class to set the self.app_name variable to name of the application. +If the application is a MET tool, then set self.app_path to the full path +of the tool under **MET_BIN_DIR**. See the Basic Components :ref:`bc_init_function` section for more information:: def __init__(self, config, instance=None, config_overrides=None): @@ -90,6 +104,43 @@ See the Basic Components :ref:`bc_init_function` section for more information:: instance=instance, config_overrides=config_overrides) +Read Configuration Variables +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The create_c_dict function is called during the initialization step of each +wrapper. It is where values from the self.config object are read. +The values are stored in the **c_dict** variable that is referenced +throughout the wrapper execution via self.c_dict. + +The function should always start with a call to the parent class' +implementation of the function to read/set any variables that are common to +all wrappers:: + + c_dict = super().create_c_dict() + +The function should also always return the c_dict variable:: + + return c_dict + +File Input/Output +""""""""""""""""" + +METplus configuration variables that end with _DIR and _TEMPLATE are used +to define the criteria to search for input files. + +Allow Multiple Files +"""""""""""""""""""" + +If the application can take more than one file as input for a given category +(i.e. FCST, OBS, ENS, etc.) then ALLOW_MULTIPLE_FILES must be set to True:: + + c_dict['ALLOW_MULTIPLE_FILES'] = True + +This is set to False by default in CommandBuilder's create_c_dict function. +If it is set to False and a list of files are found for an input +(using wildcards or a list of files in the METplus config template variable) +then the wrapper will produce an error and not build the command. + Run Functions ^^^^^^^^^^^^^ @@ -182,6 +233,9 @@ Your use case/example configuration file is located in a directory structure lik Note the documentation file is in METplus/docs while the use case conf file is in METplus/parm +Refer to the :ref:`basic_components_of_wrappers` section of the Contributor's +Guide for more information on what should be added. + Documentation ------------- diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index 3484ae5beb..3b5cee3f5f 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -497,7 +497,7 @@ METplus Configuration Glossary ASCII2NC_CONFIG_FILE Path to optional configuration file read by ascii2nc. To utilize a configuration file, set this to - {PARM_BASE}/parm/met_config/Ascii2NcConfig_wrapped. + {PARM_BASE}/met_config/Ascii2NcConfig_wrapped. If unset, no config file will be used. | *Used by:* ASCII2NC @@ -640,7 +640,7 @@ METplus Configuration Glossary | *Used by:* ASCII2NC ASCII2NC_FILE_WINDOW_END - Used to control the upper bound of the window around the valid time to determine if an ASCII2NC input file should be used for processing. Overrides :term:`OBS_FILE_WINDOW_BEGIN`. See 'Use Windows to Find Valid Files' section for more information. + Used to control the upper bound of the window around the valid time to determine if an ASCII2NC input file should be used for processing. Overrides :term:`OBS_FILE_WINDOW_END`. See 'Use Windows to Find Valid Files' section for more information. | *Used by:* ASCII2NC @@ -8266,6 +8266,260 @@ METplus Configuration Glossary | *Used by:* GenEnsProd + LOG_IODA2NC_VERBOSITY + Overrides the log verbosity for IODA2NC only. + If not set, the verbosity level is controlled by :term:`LOG_MET_VERBOSITY`. + + | *Used by:* IODA2NC + + IODA2NC_CUSTOM_LOOP_LIST + Sets custom string loop list for a specific wrapper. + See :term:`CUSTOM_LOOP_LIST`. + + | *Used by:* IODA2NC + + IODA2NC_FILE_WINDOW_BEG + Used to control the lower bound of the window around the valid time to + determine if an IODA2NC input file should be used for processing. + Overrides :term:`OBS_FILE_WINDOW_BEGIN`. + See 'Use Windows to Find Valid Files' section for more information. + + | *Used by:* IODA2NC + + IODA2NC_FILE_WINDOW_END + Used to control the upper bound of the window around the valid time to + determine if an IODA2NC input file should be used for processing. + Overrides :term:`OBS_FILE_WINDOW_END`. + See 'Use Windows to Find Valid Files' section for more information. + + | *Used by:* IODA2NC + + IODA2NC_SKIP_IF_OUTPUT_EXISTS + If True, do not run IODA2NC if output file already exists. Set to False to overwrite files. + + | *Used by:* IODA2NC + + IODA2NC_INPUT_DIR + Directory containing input data to IODA2NC. + This variable is optional because you can specify the full path to the + input files using :term:`IODA2NC_INPUT_TEMPLATE`. + + | *Used by:* IODA2NC + + IODA2NC_INPUT_TEMPLATE + Filename template of the input file used by IODA2NC. + See also :term:`IODA2NC_INPUT_DIR`. + + | *Used by:* IODA2NC + + IODA2NC_OUTPUT_DIR + Directory to write output data generated by IODA2NC. + This variable is optional because you can specify the full path to the + output files using :term:`IODA2NC_OUTPUT_TEMPLATE`. + + | *Used by:* IODA2NC + + IODA2NC_OUTPUT_TEMPLATE + Filename template of the output file generated by IODA2NC. + See also :term:`IODA2NC_OUTPUT_DIR`. + + | *Used by:* IODA2NC + + IODA2NC_VALID_BEG + Used to set the command line argument -valid_beg that controls the + lower bound of valid times of data to use. + Filename template notation can be used, i.e. {valid?fmt=%Y%m%d_%H%M%S} + + | *Used by:* IODA2NC + + IODA2NC_VALID_END + Used to set the command line argument -valid_end that controls the + upper bound of valid times of data to use. + Filename template notation can be used, i.e. + {valid?fmt=%Y%m%d_%H%M%S?shift=1d} (valid time shifted forward one day) + + | *Used by:* IODA2NC + + IODA2NC_NMSG + Used to set the command line argument -nmsg for ioda2nc. + + | *Used by:* IODA2NC + + IODA2NC_CONFIG_FILE + Path to wrapped MET configuration file read by ioda2nc. + If unset, {PARM_BASE}/met_config/IODA2NCConfig_wrapped will be used. + + | *Used by:* IODA2NC + + IODA2NC_MESSAGE_TYPE + Specify the value for 'message_type' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_MESSAGE_TYPE_MAP + Specify the value for 'message_type_map' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_MESSAGE_TYPE_GROUP_MAP + Specify the value for 'message_type_group_map' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_STATION_ID + Specify the value for 'station_id' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_OBS_WINDOW_BEG + Specify the value for 'obs_window.beg' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_OBS_WINDOW_END + Specify the value for 'obs_window.end' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_MASK_GRID + Specify the value for 'mask.grid' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_MASK_POLY + Specify the value for 'mask.poly' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_ELEVATION_RANGE_BEG + Specify the value for 'elevation_range.beg' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_ELEVATION_RANGE_END + Specify the value for 'elevation_range.end' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_LEVEL_RANGE_BEG + Specify the value for 'level_range.beg' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_LEVEL_RANGE_END + Specify the value for 'level_range.end' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_OBS_VAR + Specify the value for 'obs_var' in the MET configuration file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_OBS_NAME_MAP + Specify the value for 'obs_name_map' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_METADATA_MAP + Specify the value for 'metadata_map' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_MISSING_THRESH + Specify the value for 'missing_thresh' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_QUALITY_MARK_THRESH + Specify the value for 'quality_mark_thresh' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_FLAG + Specify the value for 'time_summary.flag' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_RAW_DATA + Specify the value for 'time_summary.raw_data' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_BEG + Specify the value for 'time_summary.beg' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_END + Specify the value for 'time_summary.end' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_STEP + Specify the value for 'time_summary.step' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_WIDTH + Specify the value for 'time_summary.width' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_GRIB_CODE + Specify the value for 'time_summary.grib_code' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_OBS_VAR + Specify the value for 'time_summary.obs_var' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_TYPE + Specify the value for 'time_summary.type' in the MET configuration file + for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_VLD_FREQ + Specify the value for 'time_summary.vld_freq' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_TIME_SUMMARY_VLD_THRESH + Specify the value for 'time_summary.vld_thresh' in the MET configuration + file for IODA2NC. + + | *Used by:* IODA2NC + + IODA2NC_MET_CONFIG_OVERRIDES + Override any variables in the MET configuration file that are not + supported by the wrapper. This should be set to the full variable name + and value that you want to override, including the equal sign and the + ending semi-colon. The value is directly appended to the end of the + wrapped MET config file. + + Example: + IODA2NC_MET_CONFIG_OVERRIDES = desc = "override_desc"; model = "override_model"; + + See :ref:`Overriding Unsupported MET config file settings` for more information + + | *Used by:* IODA2NC + ENSEMBLE_STAT_OBS_QUALITY_INC Specify the value for 'obs_quality_inc' in the MET configuration file for EnsembleStat. diff --git a/docs/Users_Guide/quicksearch.rst b/docs/Users_Guide/quicksearch.rst index b758b1fd8c..4249e3b38c 100644 --- a/docs/Users_Guide/quicksearch.rst +++ b/docs/Users_Guide/quicksearch.rst @@ -20,6 +20,7 @@ Use Cases by MET Tool: | `GenEnsProd <../search.html?q=GenEnsProdToolUseCase&check_keywords=yes&area=default>`_ | `GridStat <../search.html?q=GridStatToolUseCase&check_keywords=yes&area=default>`_ | `GridDiag <../search.html?q=GridDiagToolUseCase&check_keywords=yes&area=default>`_ + | `IODA2NC <../search.html?q=IODA2NCToolUseCase&check_keywords=yes&area=default>`_ | `MODE <../search.html?q=MODEToolUseCase&check_keywords=yes&area=default>`_ | `MTD <../search.html?q=MTDToolUseCase&check_keywords=yes&area=default>`_ | `PB2NC <../search.html?q=PB2NCToolUseCase&check_keywords=yes&area=default>`_ @@ -45,6 +46,7 @@ Use Cases by MET Tool: | **GenEnsProd**: *GenEnsProdToolUseCase* | **GridStat**: *GridStatToolUseCase* | **GridDiag**: *GridDiagToolUseCase* + | **IODA2NC**: *IODA2NCToolUseCase* | **MODE**: *MODEToolUseCase* | **MTD**: *MTDToolUseCase* | **PB2NC**: *PB2NCToolUseCase* diff --git a/docs/Users_Guide/wrappers.rst b/docs/Users_Guide/wrappers.rst index 18fc97d57e..40df66983e 100644 --- a/docs/Users_Guide/wrappers.rst +++ b/docs/Users_Guide/wrappers.rst @@ -3266,6 +3266,275 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`GRID_STAT_DISTANCE_MAP_BETA_VALUE_N` - distance_map.beta_value(n) +.. _ioda2nc_wrapper: + +IODA2NC +======== + +Description +----------- + +Used to configure the MET tool ioda2nc + +METplus Configuration +--------------------- + +| :term:`IODA2NC_INPUT_DIR` +| :term:`IODA2NC_INPUT_TEMPLATE` +| :term:`IODA2NC_OUTPUT_DIR` +| :term:`IODA2NC_OUTPUT_TEMPLATE` +| :term:`LOG_IODA2NC_VERBOSITY` +| :term:`IODA2NC_SKIP_IF_OUTPUT_EXISTS` +| :term:`IODA2NC_CONFIG_FILE` +| :term:`IODA2NC_FILE_WINDOW_BEG` +| :term:`IODA2NC_FILE_WINDOW_END` +| :term:`IODA2NC_VALID_BEG` +| :term:`IODA2NC_VALID_END` +| :term:`IODA2NC_NMSG` +| :term:`IODA2NC_MESSAGE_TYPE` +| :term:`IODA2NC_MESSAGE_TYPE_MAP` +| :term:`IODA2NC_MESSAGE_TYPE_GROUP_MAP` +| :term:`IODA2NC_STATION_ID` +| :term:`IODA2NC_OBS_WINDOW_BEG` +| :term:`IODA2NC_OBS_WINDOW_END` +| :term:`IODA2NC_MASK_GRID` +| :term:`IODA2NC_MASK_POLY` +| :term:`IODA2NC_ELEVATION_RANGE_BEG` +| :term:`IODA2NC_ELEVATION_RANGE_END` +| :term:`IODA2NC_LEVEL_RANGE_BEG` +| :term:`IODA2NC_LEVEL_RANGE_END` +| :term:`IODA2NC_OBS_VAR` +| :term:`IODA2NC_OBS_NAME_MAP` +| :term:`IODA2NC_METADATA_MAP` +| :term:`IODA2NC_MISSING_THRESH` +| :term:`IODA2NC_QUALITY_MARK_THRESH` +| :term:`IODA2NC_TIME_SUMMARY_FLAG` +| :term:`IODA2NC_TIME_SUMMARY_RAW_DATA` +| :term:`IODA2NC_TIME_SUMMARY_BEG` +| :term:`IODA2NC_TIME_SUMMARY_END` +| :term:`IODA2NC_TIME_SUMMARY_STEP` +| :term:`IODA2NC_TIME_SUMMARY_WIDTH` +| :term:`IODA2NC_TIME_SUMMARY_GRIB_CODE` +| :term:`IODA2NC_TIME_SUMMARY_OBS_VAR` +| :term:`IODA2NC_TIME_SUMMARY_TYPE` +| :term:`IODA2NC_TIME_SUMMARY_VLD_FREQ` +| :term:`IODA2NC_TIME_SUMMARY_VLD_THRESH` +| :term:`IODA2NC_CUSTOM_LOOP_LIST` +| :term:`IODA2NC_MET_CONFIG_OVERRIDES` + +.. _ioda2nc-met-conf: + +MET Configuration +----------------- + +Below is the wrapped MET configuration file used for this wrapper. +Environment variables are used to control entries in this configuration file. +The default value for each environment variable is obtained from +(except where noted below): + +`MET_INSTALL_DIR/share/met/config/IODA2NCConfig_default `_ + +Below the file contents are descriptions of each environment variable +referenced in this file and the corresponding METplus configuration item used +to set the value of the environment variable. For detailed examples showing +how METplus sets the values of these environment variables, +see :ref:`How METplus controls MET config file settings`. + +.. literalinclude:: ../../parm/met_config/IODA2NCConfig_wrapped + +**${METPLUS_MESSAGE_TYPE}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_MESSAGE_TYPE` + - message_type + +**${METPLUS_MESSAGE_TYPE_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_MESSAGE_TYPE_MAP` + - message_type_map + +**${METPLUS_MESSAGE_TYPE_GROUP_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_MESSAGE_TYPE_GROUP_MAP` + - message_type_group_map + +**${METPLUS_STATION_ID}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_STATION_ID` + - station_id + +**${METPLUS_OBS_WINDOW_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_OBS_WINDOW_BEG` + - obs_window.beg + * - :term:`IODA2NC_OBS_WINDOW_END` + - obs_window.end + +**${METPLUS_MASK_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_MASK_GRID` + - mask.grid + * - :term:`IODA2NC_MASK_POLY` + - mask.poly + +**${METPLUS_ELEVATION_RANGE_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_ELEVATION_RANGE_BEG` + - elevation_range.beg + * - :term:`IODA2NC_ELEVATION_RANGE_END` + - elevation_range.end + +**${METPLUS_LEVEL_RANGE_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_LEVEL_RANGE_BEG` + - level_range.beg + * - :term:`IODA2NC_LEVEL_RANGE_END` + - level_range.end + +**${METPLUS_OBS_VAR}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_OBS_VAR` + - obs_var + +**${METPLUS_OBS_NAME_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_OBS_NAME_MAP` + - obs_name_map + +**${METPLUS_METADATA_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_METADATA_MAP` + - metadata_map + +**${METPLUS_MISSING_THRESH}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_MISSING_THRESH` + - missing_thresh + +**${METPLUS_QUALITY_MARK_THRESH}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_QUALITY_MARK_THRESH` + - quality_mark_thresh + +**${METPLUS_TIME_SUMMARY_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_TIME_SUMMARY_FLAG` + - time_summary.flag + * - :term:`IODA2NC_TIME_SUMMARY_RAW_DATA` + - time_summary.raw_data + * - :term:`IODA2NC_TIME_SUMMARY_BEG` + - time_summary.beg + * - :term:`IODA2NC_TIME_SUMMARY_END` + - time_summary.end + * - :term:`IODA2NC_TIME_SUMMARY_STEP` + - time_summary.step + * - :term:`IODA2NC_TIME_SUMMARY_WIDTH` + - time_summary.width + * - :term:`IODA2NC_TIME_SUMMARY_GRIB_CODE` + - time_summary.grib_code + * - :term:`IODA2NC_TIME_SUMMARY_OBS_VAR` + - time_summary.obs_var + * - :term:`IODA2NC_TIME_SUMMARY_TYPE` + - time_summary.type + * - :term:`IODA2NC_TIME_SUMMARY_VLD_FREQ` + - time_summary.vld_freq + * - :term:`IODA2NC_TIME_SUMMARY_VLD_THRESH` + - time_summary.vld_thresh + +**${METPLUS_MET_CONFIG_OVERRIDES}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`IODA2NC_MET_CONFIG_OVERRIDES` + - n/a + .. _make_plots_wrapper: MakePlots diff --git a/docs/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.py b/docs/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.py new file mode 100644 index 0000000000..6e997293f7 --- /dev/null +++ b/docs/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.py @@ -0,0 +1,110 @@ +""" +IODA2NC: Basic Use Case +======================= + +met_tool_wrapper/IODA2NC/IODA2NC.conf + +""" +############################################################################## +# Scientific Objective +# -------------------- +# +# Convert IODA NetCDF files to MET NetCDF format. + +############################################################################## +# Datasets +# -------- +# +# **Input:** IODA NetCDF observation +# +# **Location:** All of the input data required for this use case can be found +# in the met_test sample data tarball. Click here to the METplus releases +# page and download sample data for the appropriate release: +# https://github.com/dtcenter/METplus/releases +# This tarball should be unpacked into the directory that you will set the +# value of INPUT_BASE. See the `Running METplus`_ section for more information. +# + +############################################################################## +# METplus Components +# ------------------ +# +# This use case utilizes the METplus IODA2NC wrapper to generate a command +# to run the MET tool ioda2nc if all required files are found. + +############################################################################## +# METplus Workflow +# ---------------- +# +# IODA2NC is the only tool called in this example. +# It processes the following run time(s): +# +# | **Valid:** 2020-03-10 12Z +# + +############################################################################## +# METplus Configuration +# --------------------- +# +# parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf + +############################################################################## +# MET Configuration +# --------------------- +# +# .. note:: +# See the :ref:`IODA2NC MET Configuration` +# section of the User's Guide for more information on the environment +# variables used in the file below. +# +# parm/met_config/IODA2NCConfig_wrapped +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/met_config/IODA2NCConfig_wrapped + +############################################################################## +# Running METplus +# --------------- +# +# Provide the use case .conf configuration file to the run_metplus.py script. +# +# /path/to/METplus/parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf +# +# See the :ref:`running-metplus` section of the System Configuration chapter +# for more details. +# + +############################################################################## +# Expected Output +# --------------- +# +# A successful run will output the following to the screen and the logfile:: +# +# INFO: METplus has successfully finished running. +# +# Refer to the value set for **OUTPUT_BASE** to find where the output data +# was generated. Output for this use case will be found in +# met_tool_wrapper/ioda2nc +# (relative to **OUTPUT_BASE**) +# and will contain the following file(s): +# +# * ioda.NC001007.2020031012.summary.nc +# + +############################################################################## +# Keywords +# -------- +# +# .. note:: +# +# * IODA2NCToolUseCase +# +# Navigate to :ref:`quick-search` to discover other similar use cases. +# +# +# +# sphinx_gallery_thumbnail_path = '_static/met_tool_wrapper-IODA2NC.png' +# diff --git a/docs/use_cases/met_tool_wrapper/IODA2NC/README.rst b/docs/use_cases/met_tool_wrapper/IODA2NC/README.rst new file mode 100644 index 0000000000..84d389d33c --- /dev/null +++ b/docs/use_cases/met_tool_wrapper/IODA2NC/README.rst @@ -0,0 +1,2 @@ +IODA2NC +------- diff --git a/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py b/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py index 48da2ba78d..ed27e0784f 100644 --- a/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py +++ b/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py @@ -51,60 +51,6 @@ def set_minimum_config_settings(config): config.set('config', 'ENS_VAR1_NAME', ens_name) config.set('config', 'ENS_VAR1_LEVELS', ens_level) -@pytest.mark.parametrize( - 'config_overrides, env_var_values', [ - # 0 no climo settings - ({}, {}), - # 1 mean template only - ({'GEN_ENS_PROD_CLIMO_MEAN_INPUT_TEMPLATE': 'gs_mean_{init?fmt=%Y%m%d%H}.tmpl'}, - {'CLIMO_MEAN_FILE': '"gs_mean_YMDH.tmpl"', - 'CLIMO_STDEV_FILE': '', }), - # 2 mean template and dir - ({'GEN_ENS_PROD_CLIMO_MEAN_INPUT_TEMPLATE': 'gs_mean_{init?fmt=%Y%m%d%H}.tmpl', - 'GEN_ENS_PROD_CLIMO_MEAN_INPUT_DIR': '/climo/mean/dir'}, - {'CLIMO_MEAN_FILE': '"/climo/mean/dir/gs_mean_YMDH.tmpl"', - 'CLIMO_STDEV_FILE': '', }), - # 3 stdev template only - ({'GEN_ENS_PROD_CLIMO_STDEV_INPUT_TEMPLATE': 'gs_stdev_{init?fmt=%Y%m%d%H}.tmpl'}, - {'CLIMO_STDEV_FILE': '"gs_stdev_YMDH.tmpl"', }), - # 4 stdev template and dir - ({'GEN_ENS_PROD_CLIMO_STDEV_INPUT_TEMPLATE': 'gs_stdev_{init?fmt=%Y%m%d%H}.tmpl', - 'GEN_ENS_PROD_CLIMO_STDEV_INPUT_DIR': '/climo/stdev/dir'}, - {'CLIMO_STDEV_FILE': '"/climo/stdev/dir/gs_stdev_YMDH.tmpl"', }), - ] -) -def test_handle_climo_file_variables(metplus_config, config_overrides, - env_var_values): - """! Ensure that old and new variables for setting climo_mean and - climo_stdev are set to the correct values - """ - old_env_vars = ['CLIMO_MEAN_FILE', - 'CLIMO_STDEV_FILE'] - config = metplus_config() - - set_minimum_config_settings(config) - - # set config variable overrides - for key, value in config_overrides.items(): - config.set('config', key, value) - - wrapper = GenEnsProdWrapper(config) - assert wrapper.isOK - - all_cmds = wrapper.run_all_times() - for (_, actual_env_vars), run_time in zip(all_cmds, run_times): - run_dt = datetime.strptime(run_time, time_fmt) - ymdh = run_dt.strftime('%Y%m%d%H') - print(f"ACTUAL ENV VARS: {actual_env_vars}") - for old_env in old_env_vars: - match = next((item for item in actual_env_vars if - item.startswith(old_env)), None) - assert(match is not None) - actual_value = match.split('=', 1)[1] - expected_value = env_var_values.get(old_env, '') - expected_value = expected_value.replace('YMDH', ymdh) - assert(expected_value == actual_value) - @pytest.mark.parametrize( 'config_overrides, env_var_values', [ ({'MODEL': 'my_model'}, diff --git a/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py b/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py new file mode 100644 index 0000000000..47030515d3 --- /dev/null +++ b/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py @@ -0,0 +1,246 @@ +import os + +import pytest + +from metplus.wrappers.ioda2nc_wrapper import IODA2NCWrapper + + +time_fmt = '%Y%m%d%H' +run_times = ['2020031012', '2020031100'] + +def set_minimum_config_settings(config): + config.set('config', 'DO_NOT_RUN_EXE', True) + config.set('config', 'INPUT_MUST_EXIST', False) + + # set process and time config variables + config.set('config', 'PROCESS_LIST', 'IODA2NC') + config.set('config', 'LOOP_BY', 'VALID') + config.set('config', 'VALID_TIME_FMT', time_fmt) + config.set('config', 'VALID_BEG', run_times[0]) + config.set('config', 'VALID_END', run_times[-1]) + config.set('config', 'VALID_INCREMENT', '12H') + config.set('config', 'LOOP_ORDER', 'times') + config.set('config', 'IODA2NC_INPUT_DIR', + '{INPUT_BASE}/met_test/new/ioda') + config.set('config', 'IODA2NC_INPUT_TEMPLATE', + 'ioda.NC001007.{valid?fmt=%Y%m%d%H}.nc') + config.set('config', 'IODA2NC_OUTPUT_DIR', + '{OUTPUT_BASE}/ioda2nc') + config.set('config', 'IODA2NC_OUTPUT_TEMPLATE', + 'ioda.NC001007.{valid?fmt=%Y%m%d%H}.summary.nc') + +@pytest.mark.parametrize( + 'config_overrides, env_var_values, extra_args', [ + # 0 + ({'IODA2NC_MESSAGE_TYPE': 'ADPUPA, ADPSFC', }, + {'METPLUS_MESSAGE_TYPE': 'message_type = ["ADPUPA", "ADPSFC"];'}, ''), + # 1 + ({'IODA2NC_MESSAGE_TYPE_MAP': '{ key = “AIRCAR”; val = “AIRCAR_PROFILES”; }', }, + {'METPLUS_MESSAGE_TYPE_MAP': 'message_type_map = [{ key = “AIRCAR”; val = “AIRCAR_PROFILES”; }];'}, ''), + # 2 + ({'IODA2NC_MESSAGE_TYPE_GROUP_MAP': '{ key = "SURFACE"; val = "ADPSFC,SFCSHP,MSONET";},{ key = "ANYAIR"; val = "AIRCAR,AIRCFT";}', }, + {'METPLUS_MESSAGE_TYPE_GROUP_MAP': 'message_type_group_map = [{ key = "SURFACE"; val = "ADPSFC, SFCSHP, MSONET";}, { key = "ANYAIR"; val = "AIRCAR, AIRCFT";}];'}, ''), + # 3 + ({'IODA2NC_STATION_ID': 'value1, value2', }, + {'METPLUS_STATION_ID': 'station_id = ["value1", "value2"];'}, ''), + # 4 + ({'IODA2NC_OBS_WINDOW_BEG': '-5400', }, + {'METPLUS_OBS_WINDOW_DICT': 'obs_window = {beg = -5400;}'}, ''), + # 5 + ({'IODA2NC_OBS_WINDOW_END': '5400', }, + {'METPLUS_OBS_WINDOW_DICT': 'obs_window = {end = 5400;}'}, ''), + # 6 + ({ + 'IODA2NC_OBS_WINDOW_BEG': '-5400', + 'IODA2NC_OBS_WINDOW_END': '5400', + }, + {'METPLUS_OBS_WINDOW_DICT': 'obs_window = {beg = -5400;end = 5400;}'} + , ''), + # 7 + ({'IODA2NC_MASK_GRID': 'FULL', }, + {'METPLUS_MASK_DICT': 'mask = {grid = "FULL";}'}, ''), + # 8 + ({'IODA2NC_MASK_POLY': '/some/polyfile.nc', }, + {'METPLUS_MASK_DICT': 'mask = {poly = "/some/polyfile.nc";}'}, ''), + # 9 + ({ + 'IODA2NC_MASK_GRID': 'FULL', + 'IODA2NC_MASK_POLY': '/some/polyfile.nc', + }, + {'METPLUS_MASK_DICT': 'mask = {grid = "FULL";poly = "/some/polyfile.nc";}'}, ''), + # 10 + ({'IODA2NC_ELEVATION_RANGE_BEG': '-1000', }, + {'METPLUS_ELEVATION_RANGE_DICT': 'elevation_range = {beg = -1000;}'}, ''), + # 11 + ({'IODA2NC_ELEVATION_RANGE_END': '100000', }, + {'METPLUS_ELEVATION_RANGE_DICT': 'elevation_range = {end = 100000;}'}, ''), + # 12 + ({ + 'IODA2NC_ELEVATION_RANGE_BEG': '-1000', + 'IODA2NC_ELEVATION_RANGE_END': '100000', + }, + {'METPLUS_ELEVATION_RANGE_DICT': 'elevation_range = {beg = -1000;end = 100000;}'}, ''), + # 13 + ({'IODA2NC_LEVEL_RANGE_BEG': '1', }, + {'METPLUS_LEVEL_RANGE_DICT': 'level_range = {beg = 1;}'}, ''), + # 14 + ({'IODA2NC_LEVEL_RANGE_END': '255', }, + {'METPLUS_LEVEL_RANGE_DICT': 'level_range = {end = 255;}'}, ''), + # 15 + ({ + 'IODA2NC_LEVEL_RANGE_BEG': '1', + 'IODA2NC_LEVEL_RANGE_END': '255', + }, + {'METPLUS_LEVEL_RANGE_DICT': 'level_range = {beg = 1;end = 255;}'}, ''), + # 16 + ({'IODA2NC_OBS_VAR': 'TMP,WDIR,RH', }, + {'METPLUS_OBS_VAR': 'obs_var = ["TMP", "WDIR", "RH"];'}, ''), + # 17 + ({'IODA2NC_OBS_NAME_MAP': '{ key = "message_type"; val = "msg_type"; },{ key = "station_id"; val = "report_identifier"; }', }, + {'METPLUS_OBS_NAME_MAP': 'obs_name_map = [{ key = "message_type"; val = "msg_type"; }, { key = "station_id"; val = "report_identifier"; }];'}, ''), + # 18 + ({'IODA2NC_METADATA_MAP': '{ key = "message_type"; val = "msg_type"; },{ key = "station_id"; val = "report_identifier"; }', }, + {'METPLUS_METADATA_MAP': 'metadata_map = [{ key = "message_type"; val = "msg_type"; }, { key = "station_id"; val = "report_identifier"; }];'}, ''), + # 19 + ({'IODA2NC_MISSING_THRESH': '<=-1e9, >=1e9, ==-9999', }, + {'METPLUS_MISSING_THRESH': 'missing_thresh = [<=-1e9, >=1e9, ==-9999];'}, ''), + # 20 + ({'IODA2NC_QUALITY_MARK_THRESH': '2', }, + {'METPLUS_QUALITY_MARK_THRESH': 'quality_mark_thresh = 2;'}, ''), + # 21 + ({}, + {'METPLUS_TIME_SUMMARY_DICT': ''}, ''), + # 22 + ({'IODA2NC_TIME_SUMMARY_FLAG': 'True'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {flag = TRUE;}'}, ''), + # 23 + ({'IODA2NC_TIME_SUMMARY_RAW_DATA': 'true'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {raw_data = TRUE;}'}, ''), + # 24 + ({'IODA2NC_TIME_SUMMARY_BEG': '123456'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {beg = "123456";}'}, ''), + # 25 + ({'IODA2NC_TIME_SUMMARY_END': '123456'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {end = "123456";}'}, ''), + # 26 + ({'IODA2NC_TIME_SUMMARY_STEP': '500'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {step = 500;}'}, ''), + # 27 + ({'IODA2NC_TIME_SUMMARY_WIDTH': '900'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {width = 900;}'}, ''), + # 28 width as dictionary + ({'IODA2NC_TIME_SUMMARY_WIDTH': '{ beg = -21600; end = 0; }'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {width = { beg = -21600; end = 0; };}'}, ''), + # 29 + ({'IODA2NC_TIME_SUMMARY_GRIB_CODE': '12, 203, 212'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {grib_code = [12, 203, 212];}'}, ''), + # 30 + ({'IODA2NC_TIME_SUMMARY_OBS_VAR': 'TMP, HGT, PRES'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {obs_var = ["TMP", "HGT", "PRES"];}'}, ''), + # 31 + ({'IODA2NC_TIME_SUMMARY_TYPE': 'min, range, max'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {type = ["min", "range", "max"];}'}, ''), + # 32 + ({'IODA2NC_TIME_SUMMARY_VALID_FREQ': '2'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {vld_freq = 2;}'}, ''), + # 33 + ({'IODA2NC_TIME_SUMMARY_VALID_THRESH': '0.5'}, + {'METPLUS_TIME_SUMMARY_DICT': + 'time_summary = {vld_thresh = 0.5;}'}, ''), + # 34 additional input file with full path + ({'IODA2NC_INPUT_TEMPLATE': 'ioda.NC001007.{valid?fmt=%Y%m%d%H}.nc, /other/file.nc'}, + {}, ' -iodafile /other/file.nc'), + # 35 additional input file with relative path + ({'IODA2NC_INPUT_TEMPLATE': 'ioda.NC001007.{valid?fmt=%Y%m%d%H}.nc, other/file.nc'}, + {}, ' -iodafile *INPUT_DIR*/other/file.nc'), + # 36 + ({'IODA2NC_VALID_BEG': '20200309_12'}, + {}, ' -valid_beg 20200309_12'), + # 37 + ({'IODA2NC_VALID_END': '20200310_12'}, + {}, ' -valid_end 20200310_12'), + # 38 + ({'IODA2NC_NMSG': '10'}, + {}, ' -nmsg 10'), + # 39 all optional command line args + ({'IODA2NC_INPUT_TEMPLATE': 'ioda.NC001007.{valid?fmt=%Y%m%d%H}.nc, other/file.nc', + 'IODA2NC_VALID_BEG': '20200309_12', + 'IODA2NC_VALID_END': '20200310_12', + 'IODA2NC_NMSG': '10', + }, + {}, ' -iodafile *INPUT_DIR*/other/file.nc -valid_beg 20200309_12 -valid_end 20200310_12 -nmsg 10'), + ] +) +def test_ioda2nc_wrapper(metplus_config, config_overrides, + env_var_values, extra_args): + config = metplus_config() + + set_minimum_config_settings(config) + + # set config variable overrides + for key, value in config_overrides.items(): + config.set('config', key, value) + + wrapper = IODA2NCWrapper(config) + assert wrapper.isOK + + input_dir = wrapper.c_dict.get('OBS_INPUT_DIR') + output_dir = wrapper.c_dict.get('OUTPUT_DIR') + + app_path = os.path.join(config.getdir('MET_BIN_DIR'), wrapper.app_name) + verbosity = f"-v {wrapper.c_dict['VERBOSITY']}" + config_file = wrapper.c_dict.get('CONFIG_FILE') + + extra_args = extra_args.replace('*INPUT_DIR*', input_dir) + expected_cmds = [ + (f"{app_path} {verbosity} {input_dir}/ioda.NC001007.2020031012.nc" + f" {output_dir}/ioda.NC001007.2020031012.summary.nc" + f" -config {config_file}{extra_args}"), + (f"{app_path} {verbosity} {input_dir}/ioda.NC001007.2020031100.nc" + f" {output_dir}/ioda.NC001007.2020031100.summary.nc" + f" -config {config_file}{extra_args}"), + ] + + all_cmds = wrapper.run_all_times() + print(f"ALL COMMANDS: {all_cmds}") + assert len(all_cmds) == len(expected_cmds) + + for (cmd, env_vars), expected_cmd in zip(all_cmds, expected_cmds): + # ensure commands are generated as expected + assert cmd == expected_cmd + + # check that environment variables were set properly + for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + match = next((item for item in env_vars if + item.startswith(env_var_key)), None) + assert match is not None + actual_value = match.split('=', 1)[1] + assert(env_var_values.get(env_var_key, '') == actual_value) + +def test_get_config_file(metplus_config): + fake_config_name = '/my/config/file' + config = metplus_config() + config.set('config', 'INPUT_MUST_EXIST', False) + + wrapper = IODA2NCWrapper(config) + + default_config_file = os.path.join(config.getdir('PARM_BASE'), + 'met_config', + 'IODA2NCConfig_wrapped') + + assert wrapper.c_dict['CONFIG_FILE'] == default_config_file + + config.set('config', 'IODA2NC_CONFIG_FILE', fake_config_name) + wrapper = IODA2NCWrapper(config) + assert wrapper.c_dict['CONFIG_FILE'] == fake_config_name diff --git a/internal_tests/use_cases/all_use_cases.txt b/internal_tests/use_cases/all_use_cases.txt index a9ea0ea510..2d196b9b67 100644 --- a/internal_tests/use_cases/all_use_cases.txt +++ b/internal_tests/use_cases/all_use_cases.txt @@ -58,6 +58,7 @@ Category: met_tool_wrapper 56::GFDLTracker_ETC::met_tool_wrapper/GFDLTracker/GFDLTracker_ETC.conf::gfdl-tracker_env 57::GFDLTracker_Genesis::met_tool_wrapper/GFDLTracker/GFDLTracker_Genesis.conf::gfdl-tracker_env 58::GenEnsProd::met_tool_wrapper/GenEnsProd/GenEnsProd.conf +59::IODA2NC::met_tool_wrapper/IODA2NC/IODA2NC.conf Category: air_quality_and_comp 0::EnsembleStat_fcstICAP_obsMODIS_aod::model_applications/air_quality_and_comp/EnsembleStat_fcstICAP_obsMODIS_aod.conf diff --git a/metplus/util/doc_util.py b/metplus/util/doc_util.py index e72337ad6d..5bf834d86f 100755 --- a/metplus/util/doc_util.py +++ b/metplus/util/doc_util.py @@ -17,6 +17,7 @@ 'gfdltracker': 'GFDLTracker', 'griddiag': 'GridDiag', 'gridstat': 'GridStat', + 'ioda2nc': 'IODA2NC', 'makeplots': 'MakePlots', 'metdbload': 'METDbLoad', 'mode': 'MODE', diff --git a/metplus/util/met_util.py b/metplus/util/met_util.py index 4e5118b6a3..414b9795a4 100644 --- a/metplus/util/met_util.py +++ b/metplus/util/met_util.py @@ -1694,7 +1694,7 @@ def getlist(list_str, expand_begin_end_incr=True): return [] # FIRST remove surrounding comma, and spaces, form the string. - list_str = list_str.strip().strip(',').strip() + list_str = list_str.strip(';[] ').strip().strip(',').strip() # remove space around commas list_str = re.sub(r'\s*,\s*', ',', list_str) diff --git a/metplus/wrappers/ascii2nc_wrapper.py b/metplus/wrappers/ascii2nc_wrapper.py index 23d7c53e93..01e675833f 100755 --- a/metplus/wrappers/ascii2nc_wrapper.py +++ b/metplus/wrappers/ascii2nc_wrapper.py @@ -76,11 +76,11 @@ def create_c_dict(self): ) # MET config variables - self.handle_time_summary_dict(c_dict, - ['TIME_SUMMARY_GRIB_CODES', - 'TIME_SUMMARY_VAR_NAMES', - 'TIME_SUMMARY_TYPES'] - ) + self.handle_time_summary_legacy(c_dict, + ['TIME_SUMMARY_GRIB_CODES', + 'TIME_SUMMARY_VAR_NAMES', + 'TIME_SUMMARY_TYPES'] + ) # handle file window variables for edge in ['BEGIN', 'END']: diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 9e22aa6642..4384f45307 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -1984,7 +1984,43 @@ def get_env_var_value(self, env_var_name, read_dict=None, item_type=None): return mask_value.split('=', 1)[1].rstrip(';').strip() - def handle_time_summary_dict(self, c_dict, remove_bracket_list=None): + def handle_time_summary_dict(self): + """! Read METplusConfig variables for the MET config time_summary + dictionary and format values into an environment variable + METPLUS_TIME_SUMMARY_DICT that is referenced in the wrapped MET + config files. + """ + self.handle_met_config_dict('time_summary', { + 'flag': 'bool', + 'raw_data': 'bool', + 'beg': 'string', + 'end': 'string', + 'step': 'int', + 'width': ('string', 'remove_quotes'), + 'grib_code': ('list', 'remove_quotes,allow_empty', None, + ['TIME_SUMMARY_GRIB_CODES']), + 'obs_var': ('list', 'allow_empty', None, + ['TIME_SUMMARY_VAR_NAMES']), + 'type': ('list', 'allow_empty', None, ['TIME_SUMMARY_TYPES']), + 'vld_freq': ('int', None, None, ['TIME_SUMMARY_VALID_FREQ']), + 'vld_thresh': ('float', None, None, ['TIME_SUMMARY_VALID_THRESH']), + }) + + def handle_time_summary_legacy(self, c_dict, remove_bracket_list=None): + """! Read METplusConfig variables for the MET config time_summary + dictionary and format values into environment variable + METPLUS_TIME_SUMMARY_DICT as well as other environment variables + that contain individuals items of the time_summary dictionary + that were referenced in wrapped MET config files prior to METplus 4.0. + Developer note: If we discontinue support for legacy wrapped MET + config files + + @param c_dict dictionary to store time_summary item values + @param remove_bracket_list (optional) list of items that need the + square brackets around the value removed because the legacy (pre 4.0) + wrapped MET config includes square braces around the environment + variable. + """ tmp_dict = {} app = self.app_name.upper() self.set_met_config_bool(tmp_dict, @@ -2054,7 +2090,7 @@ def handle_time_summary_dict(self, c_dict, remove_bracket_list=None): time_summary = self.format_met_config_dict(tmp_dict, 'time_summary', - keys=None) + keys=None) self.env_var_dict['METPLUS_TIME_SUMMARY_DICT'] = time_summary # set c_dict values to support old method of setting env vars @@ -2307,6 +2343,11 @@ def add_met_config(self, **kwargs): in order of precedence (first variable is used if it is set, otherwise 2nd variable is used if set, etc.) """ + # if metplus_configs is not provided, use _ + if not kwargs.get('metplus_configs'): + kwargs['metplus_configs'] = [ + f"{self.app_name}_{kwargs.get('name')}".upper() + ] item = met_config(**kwargs) output_dict = kwargs.get('output_dict') self.handle_met_config_item(item, output_dict) diff --git a/metplus/wrappers/gen_ens_prod_wrapper.py b/metplus/wrappers/gen_ens_prod_wrapper.py index 3c2a4367de..00c7c58e73 100755 --- a/metplus/wrappers/gen_ens_prod_wrapper.py +++ b/metplus/wrappers/gen_ens_prod_wrapper.py @@ -59,10 +59,6 @@ def __init__(self, config, instance=None, config_overrides=None): def create_c_dict(self): c_dict = super().create_c_dict() - c_dict['VERBOSITY'] = self.config.getstr('config', - 'LOG_GEN_ENS_PROD_VERBOSITY', - c_dict['VERBOSITY']) - # get the MET config file path or use default c_dict['CONFIG_FILE'] = self.get_config_file( 'GenEnsProdConfig_wrapped' diff --git a/metplus/wrappers/ioda2nc_wrapper.py b/metplus/wrappers/ioda2nc_wrapper.py new file mode 100755 index 0000000000..3fecb4a4b0 --- /dev/null +++ b/metplus/wrappers/ioda2nc_wrapper.py @@ -0,0 +1,174 @@ +""" +Program Name: ioda2nc_wrapper.py +Contact(s): George McCabe +Abstract: Builds commands to run ioda2nc +""" + +import os + +from ..util import do_string_sub +from . import LoopTimesWrapper + +'''!@namespace IODA2NCWrapper +@brief Wraps the IODA2NC tool to reformat IODA NetCDF data to MET NetCDF +@endcode +''' + + +class IODA2NCWrapper(LoopTimesWrapper): + + WRAPPER_ENV_VAR_KEYS = [ + 'METPLUS_MESSAGE_TYPE', + 'METPLUS_MESSAGE_TYPE_GROUP_MAP', + 'METPLUS_MESSAGE_TYPE_MAP', + 'METPLUS_STATION_ID', + 'METPLUS_OBS_WINDOW_DICT', + 'METPLUS_MASK_DICT', + 'METPLUS_ELEVATION_RANGE_DICT', + 'METPLUS_LEVEL_RANGE_DICT', + 'METPLUS_OBS_VAR', + 'METPLUS_OBS_NAME_MAP', + 'METPLUS_METADATA_MAP', + 'METPLUS_MISSING_THRESH', + 'METPLUS_QUALITY_MARK_THRESH', + 'METPLUS_TIME_SUMMARY_DICT', + ] + + def __init__(self, config, instance=None, config_overrides=None): + self.app_name = "ioda2nc" + self.app_path = os.path.join(config.getdir('MET_BIN_DIR', ''), + self.app_name) + super().__init__(config, + instance=instance, + config_overrides=config_overrides) + + def create_c_dict(self): + """! Read METplusConfig object and sets values in dictionary to be + used by the wrapper to generate commands. Gets information regarding + input/output files, optional command line arguments, and values to + set in the wrapped MET config file. Calls self.log_error if any + required METplusConfig variables were not set properly which logs the + error and sets self.isOK to False which causes wrapper initialization + to fail. + + @returns dictionary containing configurations for this wrapper + """ + c_dict = super().create_c_dict() + + # file I/O + c_dict['ALLOW_MULTIPLE_FILES'] = True + c_dict['OBS_INPUT_DIR'] = self.config.getdir('IODA2NC_INPUT_DIR', '') + c_dict['OBS_INPUT_TEMPLATE'] = ( + self.config.getraw('config', 'IODA2NC_INPUT_TEMPLATE') + ) + if not c_dict['OBS_INPUT_TEMPLATE']: + self.log_error("IODA2NC_INPUT_TEMPLATE required to run") + + # handle input file window variables + self.handle_file_window_variables(c_dict, dtypes=['OBS']) + + c_dict['OUTPUT_DIR'] = self.config.getdir('IODA2NC_OUTPUT_DIR', '') + c_dict['OUTPUT_TEMPLATE'] = ( + self.config.getraw('config', 'IODA2NC_OUTPUT_TEMPLATE') + ) + + # optional command line arguments + c_dict['VALID_BEG'] = self.config.getraw('config', 'IODA2NC_VALID_BEG') + c_dict['VALID_END'] = self.config.getraw('config', 'IODA2NC_VALID_END') + c_dict['NMSG'] = self.config.getint('config', 'IODA2NC_NMSG', 0) + + # MET config variables + c_dict['CONFIG_FILE'] = self.get_config_file('IODA2NCConfig_wrapped') + + self.add_met_config(name='message_type', data_type='list') + self.add_met_config(name='message_type_map', data_type='list', + extra_args={'remove_quotes': True}) + self.add_met_config(name='message_type_group_map', data_type='list', + extra_args={'remove_quotes': True}) + self.add_met_config(name='station_id', data_type='list') + self.handle_met_config_window('obs_window') + self.handle_mask(single_value=True) + self.handle_met_config_window('elevation_range') + self.handle_met_config_window('level_range') + self.add_met_config(name='obs_var', data_type='list') + self.add_met_config(name='obs_name_map', data_type='list', + extra_args={'remove_quotes': True}) + self.add_met_config(name='metadata_map', data_type='list', + extra_args={'remove_quotes': True}) + self.add_met_config(name='missing_thresh', data_type='list', + extra_args={'remove_quotes': True}) + self.add_met_config(name='quality_mark_thresh', data_type='int') + self.handle_time_summary_dict() + + return c_dict + + def get_command(self): + """! Build the command to call ioda2nc + + @returns string containing command to run + """ + return (f"{self.app_path} -v {self.c_dict['VERBOSITY']}" + f" {self.infiles[0]} {self.get_output_path()}" + f" {' '.join(self.args)}") + + def run_at_time_once(self, time_info): + """! Process runtime and try to build command to run ioda2nc + + @param time_info dictionary containing timing information + @returns True if command was built/run successfully or + False if something went wrong + """ + # get input files + if not self.find_input_files(time_info): + return False + + # get output path + if not self.find_and_check_output_file(time_info): + return False + + # get other configurations for command + self.set_command_line_arguments(time_info) + + # set environment variables if using config file + self.set_environment_variables(time_info) + + # build command and run + return self.build() + + def find_input_files(self, time_info): + """! Get all input files for ioda2nc. Sets self.infiles list. + + @param time_info dictionary containing timing information + @returns List of files that were found or None if no files were found + """ + # get list of files even if only one is found (return_list=True) + obs_path = self.find_obs(time_info, var_info=None, return_list=True) + if obs_path is None: + return None + + self.infiles.extend(obs_path) + return self.infiles + + def set_command_line_arguments(self, time_info): + """! Set all arguments for ioda2nc command. + Note: -obs_var will be set in wrapped MET config file, not command line + + @param time_info dictionary containing timing information + """ + config_file = do_string_sub(self.c_dict['CONFIG_FILE'], **time_info) + self.args.append(f"-config {config_file}") + + # if more than 1 input file was found, add them with -iodafile + for infile in self.infiles[1:]: + self.args.append(f"-iodafile {infile}") + + if self.c_dict['VALID_BEG']: + valid_beg = do_string_sub(self.c_dict['VALID_BEG'], **time_info) + self.args.append(f"-valid_beg {valid_beg}") + + if self.c_dict['VALID_END']: + valid_end = do_string_sub(self.c_dict['VALID_END'], **time_info) + self.args.append(f"-valid_end {valid_end}") + + if self.c_dict['NMSG']: + self.args.append(f"-nmsg {self.c_dict['NMSG']}") diff --git a/metplus/wrappers/pb2nc_wrapper.py b/metplus/wrappers/pb2nc_wrapper.py index 2e8241c11f..891fc367bc 100755 --- a/metplus/wrappers/pb2nc_wrapper.py +++ b/metplus/wrappers/pb2nc_wrapper.py @@ -111,7 +111,7 @@ def create_c_dict(self): 'METPLUS_OBS_BUFR_VAR', allow_empty=True) - self.handle_time_summary_dict(c_dict) + self.handle_time_summary_legacy(c_dict) self.handle_file_window_variables(c_dict, dtypes=['OBS']) diff --git a/metplus/wrappers/runtime_freq_wrapper.py b/metplus/wrappers/runtime_freq_wrapper.py index 8e02885295..eccc0604a5 100755 --- a/metplus/wrappers/runtime_freq_wrapper.py +++ b/metplus/wrappers/runtime_freq_wrapper.py @@ -46,7 +46,7 @@ def create_c_dict(self): app_name_upper = self.app_name.upper() c_dict['VERBOSITY'] = ( - self.config.getstr('config', + self.config.getint('config', f'LOG_{app_name_upper}_VERBOSITY', c_dict['VERBOSITY']) ) diff --git a/parm/met_config/Ascii2NcConfig_wrapped b/parm/met_config/Ascii2NcConfig_wrapped index 6efa3e9675..4233450615 100644 --- a/parm/met_config/Ascii2NcConfig_wrapped +++ b/parm/met_config/Ascii2NcConfig_wrapped @@ -36,4 +36,6 @@ message_type_map = [ // //version = "V10.0"; +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/EnsembleStatConfig_wrapped b/parm/met_config/EnsembleStatConfig_wrapped index e9ef2d5667..6374340917 100644 --- a/parm/met_config/EnsembleStatConfig_wrapped +++ b/parm/met_config/EnsembleStatConfig_wrapped @@ -223,4 +223,6 @@ ${METPLUS_OUTPUT_PREFIX} //////////////////////////////////////////////////////////////////////////////// +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/GenEnsProdConfig_wrapped b/parm/met_config/GenEnsProdConfig_wrapped index df51805eba..59c794310a 100644 --- a/parm/met_config/GenEnsProdConfig_wrapped +++ b/parm/met_config/GenEnsProdConfig_wrapped @@ -103,4 +103,6 @@ ${METPLUS_ENSEMBLE_FLAG_DICT} //////////////////////////////////////////////////////////////////////////////// +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/GridDiagConfig_wrapped b/parm/met_config/GridDiagConfig_wrapped index 41592f2448..06b95662bf 100644 --- a/parm/met_config/GridDiagConfig_wrapped +++ b/parm/met_config/GridDiagConfig_wrapped @@ -33,4 +33,6 @@ ${METPLUS_DATA_DICT} ${METPLUS_MASK_DICT} +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/GridStatConfig_wrapped b/parm/met_config/GridStatConfig_wrapped index 9152297a18..fdaf8cf209 100644 --- a/parm/met_config/GridStatConfig_wrapped +++ b/parm/met_config/GridStatConfig_wrapped @@ -178,7 +178,9 @@ ${METPLUS_NC_PAIRS_FLAG_DICT} //grid_weight_flag = ${METPLUS_GRID_WEIGHT_FLAG} -tmp_dir = "/tmp"; + +tmp_dir = "${MET_TMP_DIR}"; + // output_prefix = ${METPLUS_OUTPUT_PREFIX} diff --git a/parm/met_config/IODA2NCConfig_wrapped b/parm/met_config/IODA2NCConfig_wrapped new file mode 100644 index 0000000000..ba1faa1695 --- /dev/null +++ b/parm/met_config/IODA2NCConfig_wrapped @@ -0,0 +1,117 @@ +//////////////////////////////////////////////////////////////////////////////// +// +// IODA2NC configuration file. +// +// For additional information, please see the MET Users Guide. +// +//////////////////////////////////////////////////////////////////////////////// + +// +// IODA message type +// +// message_type = [ +${METPLUS_MESSAGE_TYPE} + +// +// Mapping of message type group name to comma-separated list of values +// Derive PRMSL only for SURFACE message types +// +// message_type_group_map = [ +${METPLUS_MESSAGE_TYPE_GROUP_MAP} + +// +// Mapping of input IODA message types to output message types +// +// message_type_map = [ +${METPLUS_MESSAGE_TYPE_MAP} + +// +// IODA station ID +// +// station_id = [ +${METPLUS_STATION_ID} + +//////////////////////////////////////////////////////////////////////////////// + +// +// Observation time window +// +// obs_window = { +${METPLUS_OBS_WINDOW_DICT} + +//////////////////////////////////////////////////////////////////////////////// + +// +// Observation retention regions +// +// mask = { +${METPLUS_MASK_DICT} + +//////////////////////////////////////////////////////////////////////////////// + +// +// Observing location elevation +// +// elevation_range = { +${METPLUS_ELEVATION_RANGE_DICT} + +//////////////////////////////////////////////////////////////////////////////// + +// +// Vertical levels to retain +// +// level_range = { +${METPLUS_LEVEL_RANGE_DICT} + +/////////////////////////////////////////////////////////////////////////////// + +// +// IODA variable names to retain or derive. +// Use obs_bufr_map to rename variables in the output. +// If empty or 'all', process all available variables. +// +// obs_var = [ +${METPLUS_OBS_VAR} + +//////////////////////////////////////////////////////////////////////////////// + +// +// Mapping of input IODA variable names to output variables names. +// The default IODA map, obs_var_map, is appended to this map. +// +// obs_name_map = [ +${METPLUS_OBS_NAME_MAP} + +// +// Default mapping for Metadata. +// +// metadata_map = [ +${METPLUS_METADATA_MAP} + +// missing_thresh = [ +${METPLUS_MISSING_THRESH} + +//////////////////////////////////////////////////////////////////////////////// + +// quality_mark_thresh = +${METPLUS_QUALITY_MARK_THRESH} + +//////////////////////////////////////////////////////////////////////////////// + +// +// Time periods for the summarization +// obs_var (string array) is added and works like grib_code (int array) +// when use_var_id is enabled and variable names are saved. +// +// time_summary = { +${METPLUS_TIME_SUMMARY_DICT} + +//////////////////////////////////////////////////////////////////////////////// + +tmp_dir = "${MET_TMP_DIR}"; + +//version = "V10.0"; + +//////////////////////////////////////////////////////////////////////////////// + +${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/MODEConfig_wrapped b/parm/met_config/MODEConfig_wrapped index a08a76164c..5f0442addc 100644 --- a/parm/met_config/MODEConfig_wrapped +++ b/parm/met_config/MODEConfig_wrapped @@ -226,6 +226,8 @@ shift_right = 0; // grid squares ${METPLUS_OUTPUT_PREFIX} //version = "V10.0"; +tmp_dir = "${MET_TMP_DIR}"; + //////////////////////////////////////////////////////////////////////////////// ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/MTDConfig_wrapped b/parm/met_config/MTDConfig_wrapped index f027e44369..f8310334a2 100644 --- a/parm/met_config/MTDConfig_wrapped +++ b/parm/met_config/MTDConfig_wrapped @@ -239,6 +239,8 @@ txt_output = { ${METPLUS_OUTPUT_PREFIX} //version = "V9.0"; +tmp_dir = "${MET_TMP_DIR}"; + //////////////////////////////////////////////////////////////////////////////// ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/PB2NCConfig_wrapped b/parm/met_config/PB2NCConfig_wrapped index 29d981e4a6..25cab0375b 100644 --- a/parm/met_config/PB2NCConfig_wrapped +++ b/parm/met_config/PB2NCConfig_wrapped @@ -131,7 +131,8 @@ ${METPLUS_TIME_SUMMARY_DICT} //////////////////////////////////////////////////////////////////////////////// -tmp_dir = "/tmp"; +tmp_dir = "${MET_TMP_DIR}"; + //version = "V9.0"; //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/PointStatConfig_wrapped b/parm/met_config/PointStatConfig_wrapped index 2a654d6d23..824aed7145 100644 --- a/parm/met_config/PointStatConfig_wrapped +++ b/parm/met_config/PointStatConfig_wrapped @@ -170,7 +170,8 @@ ${METPLUS_OUTPUT_FLAG_DICT} //////////////////////////////////////////////////////////////////////////////// -tmp_dir = "/tmp"; +tmp_dir = "${MET_TMP_DIR}"; + // output_prefix = ${METPLUS_OUTPUT_PREFIX} //version = "V10.0.0"; diff --git a/parm/met_config/STATAnalysisConfig_wrapped b/parm/met_config/STATAnalysisConfig_wrapped index 2fac673f6c..f317e2aa99 100644 --- a/parm/met_config/STATAnalysisConfig_wrapped +++ b/parm/met_config/STATAnalysisConfig_wrapped @@ -101,7 +101,9 @@ wmo_fisher_stats = [ "CNT:PR_CORR", "CNT:SP_CORR", ${METPLUS_HSS_EC_VALUE} rank_corr_flag = FALSE; vif_flag = FALSE; -tmp_dir = "/tmp"; + +tmp_dir = "${MET_TMP_DIR}"; + //version = "V10.0"; ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/SeriesAnalysisConfig_wrapped b/parm/met_config/SeriesAnalysisConfig_wrapped index dfa6f5a4c1..94fd1b2629 100644 --- a/parm/met_config/SeriesAnalysisConfig_wrapped +++ b/parm/met_config/SeriesAnalysisConfig_wrapped @@ -122,7 +122,9 @@ output_stats = { //hss_ec_value = ${METPLUS_HSS_EC_VALUE} rank_corr_flag = FALSE; -tmp_dir = "/tmp"; + +tmp_dir = "${MET_TMP_DIR}"; + //version = "V10.0"; //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/TCGenConfig_wrapped b/parm/met_config/TCGenConfig_wrapped index 65655c4699..c18f895f8b 100644 --- a/parm/met_config/TCGenConfig_wrapped +++ b/parm/met_config/TCGenConfig_wrapped @@ -292,4 +292,6 @@ ${METPLUS_NC_PAIRS_GRID} // //version = "V10.0.0"; +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/TCPairsConfig_wrapped b/parm/met_config/TCPairsConfig_wrapped index c780a3d486..ce13c1db82 100644 --- a/parm/met_config/TCPairsConfig_wrapped +++ b/parm/met_config/TCPairsConfig_wrapped @@ -145,4 +145,6 @@ watch_warn = { // //version = "V9.0"; +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/TCRMWConfig_wrapped b/parm/met_config/TCRMWConfig_wrapped index 3b5cb2d7dd..66dbc2cdf8 100644 --- a/parm/met_config/TCRMWConfig_wrapped +++ b/parm/met_config/TCRMWConfig_wrapped @@ -68,4 +68,6 @@ ${METPLUS_RMW_SCALE} //////////////////////////////////////////////////////////////////////////////// +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/met_config/TCStatConfig_wrapped b/parm/met_config/TCStatConfig_wrapped index 03911f0516..cef47ecc53 100644 --- a/parm/met_config/TCStatConfig_wrapped +++ b/parm/met_config/TCStatConfig_wrapped @@ -161,4 +161,6 @@ ${METPLUS_MATCH_POINTS} // ${METPLUS_JOBS} +tmp_dir = "${MET_TMP_DIR}"; + ${METPLUS_MET_CONFIG_OVERRIDES} diff --git a/parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf b/parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf new file mode 100644 index 0000000000..2d184ac189 --- /dev/null +++ b/parm/use_cases/met_tool_wrapper/IODA2NC/IODA2NC.conf @@ -0,0 +1,83 @@ +[config] + +PROCESS_LIST = IODA2NC + +### +# Time Info +### + +LOOP_BY = VALID +VALID_TIME_FMT = %Y%m%d%H +VALID_BEG = 2020031012 +VALID_END = 2020031012 +VALID_INCREMENT = 6H + +### +# File I/O Info +### + +IODA2NC_INPUT_DIR = {INPUT_BASE}/met_test/new/ioda +IODA2NC_INPUT_TEMPLATE = ioda.NC001007.{valid?fmt=%Y%m%d%H}.nc + +IODA2NC_OUTPUT_DIR = {OUTPUT_BASE}/ioda2nc +IODA2NC_OUTPUT_TEMPLATE = ioda.NC001007.{valid?fmt=%Y%m%d%H}.summary.nc + + +# OPTIONAL CONFIGURATIONS + +### +# ioda2nc command line arguments +### + +#IODA2NC_VALID_BEG = {valid?fmt=%Y%m%d_%H?shift=-24H} +#IODA2NC_VALID_END = {valid?fmt=%Y%m%d_%H} +#IODA2NC_NMSG = 10 + + +### +# ioda2nc configuration variables +### + +#IODA2NC_MESSAGE_TYPE = + +#IODA2NC_MESSAGE_TYPE_MAP = + +#IODA2NC_MESSAGE_TYPE_GROUP_MAP = + +#IODA2NC_STATION_ID = + +IODA2NC_OBS_WINDOW_BEG = -5400 +IODA2NC_OBS_WINDOW_END = 5400 + +#IODA2NC_MASK_GRID = +#IODA2NC_MASK_POLY = + +IODA2NC_ELEVATION_RANGE_BEG = -1000 +IODA2NC_ELEVATION_RANGE_END = 100000 + +#IODA2NC_LEVEL_RANGE_BEG = 1 +#IODA2NC_LEVEL_RANGE_END = 255 + +#IODA2NC_OBS_VAR = + +IODA2NC_OBS_NAME_MAP = + { key = "wind_direction"; val = "WDIR"; }, + { key = "wind_speed"; val = "WIND"; } + +#IODA2NC_METADATA_MAP = + +#IODA2NC_MISSING_THRESH = <=-1e9, >=1e9, ==-9999 + +IODA2NC_QUALITY_MARK_THRESH = 0 + +IODA2NC_TIME_SUMMARY_FLAG = True +IODA2NC_TIME_SUMMARY_RAW_DATA = True +IODA2NC_TIME_SUMMARY_BEG = 000000 +IODA2NC_TIME_SUMMARY_END = 235959 +IODA2NC_TIME_SUMMARY_STEP = 300 +IODA2NC_TIME_SUMMARY_WIDTH = 600 +IODA2NC_TIME_SUMMARY_GRIB_CODE = +IODA2NC_TIME_SUMMARY_OBS_VAR = "WIND" +IODA2NC_TIME_SUMMARY_TYPE = "min", "max", "range", "mean", "stdev", "median", "p80" +IODA2NC_TIME_SUMMARY_VLD_FREQ = 0 +IODA2NC_TIME_SUMMARY_VLD_THRESH = 0.0 From a68c61cda9c290ff8101ccd88da9187fd9cad1cd Mon Sep 17 00:00:00 2001 From: johnhg Date: Tue, 16 Nov 2021 11:24:47 -0700 Subject: [PATCH 11/43] Add default title for the new use case issue template. --- .github/ISSUE_TEMPLATE/new_use_case.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/new_use_case.md b/.github/ISSUE_TEMPLATE/new_use_case.md index ad54e6baf7..d16e19960f 100644 --- a/.github/ISSUE_TEMPLATE/new_use_case.md +++ b/.github/ISSUE_TEMPLATE/new_use_case.md @@ -1,7 +1,7 @@ --- name: New use case about: Add a new use case -title: '' +title: 'New Use Case:' labels: 'alert: NEED ACCOUNT KEY, alert: NEED MORE DEFINITION, alert: NEED PROJECT ASSIGNMENT, type: new use case' assignees: '' From 083b80dbe342b206d3b75b403e162eb68d68b8c5 Mon Sep 17 00:00:00 2001 From: Christina Kalb Date: Tue, 16 Nov 2021 12:16:50 -0700 Subject: [PATCH 12/43] Feature 1019 harmonic preprocessing (#1272) Co-authored-by: George McCabe <23407799+georgemccabe@users.noreply.github.com> --- .github/parm/use_case_groups.json | 7 +- docs/_static/s2s-OMI_GFS_phase_diagram.png | Bin 0 -> 55990 bytes .../s2s/UserScript_fcstGFS_obsERA_OMI.py | 20 +- .../s2s/UserScript_fcstGFS_obsERA_RMM.py | 263 ----------- .../s2s/UserScript_obsERA_obsOnly_OMI.py | 141 ++++++ ...UserScript_obsERA_obsOnly_PhaseDiagram.py} | 36 +- .../s2s/UserScript_obsERA_obsOnly_RMM.py | 147 ++++++ internal_tests/use_cases/all_use_cases.txt | 7 +- .../s2s/UserScript_fcstGFS_obsERA_OMI.conf | 78 +++- .../OMI_driver.py | 3 +- .../s2s/UserScript_fcstGFS_obsERA_RMM.conf | 214 --------- .../s2s/UserScript_obsERA_obsOnly_OMI.conf | 157 +++++++ .../OMI_driver.py | 1 + ...erScript_obsERA_obsOnly_PhaseDiagram.conf} | 8 +- .../PhaseDiagram_driver.py | 0 .../save_input_files_txt.py | 0 .../s2s/UserScript_obsERA_obsOnly_RMM.conf | 436 ++++++++++++++++++ .../RMM_driver.py | 28 +- .../compute_harmonic_anomalies.py | 110 +++++ 19 files changed, 1129 insertions(+), 527 deletions(-) create mode 100644 docs/_static/s2s-OMI_GFS_phase_diagram.png delete mode 100644 docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.py create mode 100644 docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.py rename docs/use_cases/model_applications/s2s/{UserScript_fcstGFS_obsERA_PhaseDiagram.py => UserScript_obsERA_obsOnly_PhaseDiagram.py} (82%) create mode 100644 docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.py delete mode 100644 parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf create mode 100644 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf create mode 120000 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py rename parm/use_cases/model_applications/s2s/{UserScript_fcstGFS_obsERA_PhaseDiagram.conf => UserScript_obsERA_obsOnly_PhaseDiagram.conf} (92%) rename parm/use_cases/model_applications/s2s/{UserScript_fcstGFS_obsERA_PhaseDiagram => UserScript_obsERA_obsOnly_PhaseDiagram}/PhaseDiagram_driver.py (100%) rename parm/use_cases/model_applications/s2s/{UserScript_fcstGFS_obsERA_PhaseDiagram => UserScript_obsERA_obsOnly_PhaseDiagram}/save_input_files_txt.py (100%) create mode 100644 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf rename parm/use_cases/model_applications/s2s/{UserScript_fcstGFS_obsERA_RMM => UserScript_obsERA_obsOnly_RMM}/RMM_driver.py (88%) create mode 100755 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 4cb2de818b..6c5f13f407 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -136,7 +136,7 @@ }, { "category": "s2s", - "index_list": "8-9", + "index_list": "7-9", "run": false }, { @@ -144,6 +144,11 @@ "index_list": "10", "run": false }, + { + "category": "s2s", + "index_list": "11", + "run": false + }, { "category": "space_weather", "index_list": "0-1", diff --git a/docs/_static/s2s-OMI_GFS_phase_diagram.png b/docs/_static/s2s-OMI_GFS_phase_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..5ec1b3adb66614f7a9bb525374cacbf2c257c98c GIT binary patch literal 55990 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%0D1YG=!A1Si{2FwTbo$=dxB}l;;Ct~~ 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, -# `RegridDataPlaneUseCase `_, -# `PCPCombineUseCase `_ - diff --git a/docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.py b/docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.py deleted file mode 100644 index b6b618c66b..0000000000 --- a/docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -UserScript: Make RMM plots from calculated MJO indices -=========================================================================== - -model_applications/ -s2s/ -UserScript_fcstGFS_obsERA_RMM.py - -""" - -############################################################################## -# Scientific Objective -# -------------------- -# -# To compute the Real-time Multivariate MJO Index (RMM) using Outgoing Longwave Radiation (OLR), 850 hPa wind (U850), and 200 hPa wind (U200). Specifically, RMM is computed using OLR, U850, andU200 data between 15N and 15S. Anomalies of OLR, U850, and U200 are then created, 120 day day mean removed, and the data are normalized by normalization factors (generally the square root of the average variance) The anomalies are projected onto Empirical Orthogonal Function (EOF) data. The OLR is then filtered for 20 - 96 days, and regressed onto the daily EOFs. Finally, it's normalized and these normalized components are plotted on a phase diagram and timeseries plot. -# - -############################################################################## -# Datasets -# -------- -# -# * Forecast dataset: GFS Model Outgoing Longwave Radiation -# * Observation dataset: ERA Reanlaysis Outgoing Longwave Radiation. - -############################################################################## -# External Dependencies -# --------------------- -# -# You will need to use a version of Python 3.6+ that has the following packages installed:: -# -# * numpy -# * netCDF4 -# * datetime -# * xarray -# * matplotlib -# * scipy -# * pandas -# -# If the version of Python used to compile MET did not have these libraries at the time of compilation, you will need to add these packages or create a new Python environment with these packages. -# -# If this is the case, you will need to set the MET_PYTHON_EXE environment variable to the path of the version of Python you want to use. If you want this version of Python to only apply to this use case, set it in the [user_env_vars] section of a METplus configuration file.: -# -# [user_env_vars] -# MET_PYTHON_EXE = /path/to/python/with/required/packages/bin/python -# - -############################################################################## -# METplus Components -# ------------------ -# -# This use case runs the OMI driver which computes OMI and creates a phase diagram. Inputs to the OMI driver include netCDF files that are in MET's netCDF version. In addition, a txt file containing the listing of these input netCDF files is required, as well as text file listings of the EOF1 and EOF2 files. Some optional pre-processing steps include using regrid_data_plane to either regrid your data or cut the domain t0 20N - 20S. -# - -############################################################################## -# METplus Workflow -# ---------------- -# -# The OMI driver script python code is run for each lead time on the forecast and observations data. This example loops by valid time for the model pre-processing, and valid time for the other steps. This version is set to only process the OMI calculation and creating a text file listing of the EOF files, omitting the regridding, and anomaly caluclation pre-processing steps. However, the configurations for pre-processing are available for user reference. - -############################################################################## -# METplus Configuration -# --------------------- -# -# METplus first loads all of the configuration files found in parm/metplus_config, -# then it loads any configuration files passed to METplus via the command line -# i.e. parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf. -# The file OMI_driver.py runs the python program and -# UserScript_fcstGFS_obsERA_OMI/UserScript_fcstGFS_obsERA_OMI.conf sets the -# variables for all steps of the OMI use case. -# -# .. highlight:: bash -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf - -############################################################################## -# MET Configuration -# --------------------- -# -# METplus sets environment variables based on the values in the METplus configuration file. -# These variables are referenced in the MET configuration file. **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** If there is a setting in the MET configuration file that is not controlled by an environment variable, you can add additional environment variables to be set only within the METplus environment using the [user_env_vars] section of the METplus configuration files. See the 'User Defined Config' section on the 'System Configuration' page of the METplus User's Guide for more information. -# -# - -############################################################################## -# Python Scripts -# ---------------- -# -# The OMI driver script orchestrates the calculation of the MJO indices and -# the generation of a phase diagram OMI plot: -# parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py: -# -# .. highlight:: python -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py -# - -############################################################################## -# Running METplus -# --------------- -# -# This use case is run in the following ways: -# -# 1) Passing in UserScript_fcstGFS_obsERA_OMI.conf then a user-specific system configuration file:: -# -# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf -c /path/to/user_system.conf -# -# 2) Modifying the configurations in parm/metplus_config, then passing in UserScript_fcstGFS_obsERA_OMI.py:: -# -# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf -# -# The following variables must be set correctly: -# -# * **INPUT_BASE** - Path to directory where sample data tarballs are unpacked (See Datasets section to obtain tarballs). This is not required to run METplus, but it is required to run the examples in parm/use_cases -# * **OUTPUT_BASE** - Path where METplus output will be written. This must be in a location where you have write permissions -# * **MET_INSTALL_DIR** - Path to location where MET is installed locally -# -# Example User Configuration File:: -# -# [dir] -# INPUT_BASE = /path/to/sample/input/data -# OUTPUT_BASE = /path/to/output/dir -# MET_INSTALL_DIR = /path/to/met-X.Y -# - -############################################################################## -# Expected Output -# --------------- -# -# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. Output for this use case will be found in model_applications/s2s/UserScript_fcstGFS_obsERA_OMI. This may include the regridded data and daily averaged files. In addition, the phase diagram plots will be generated and the output location can be specified as OMI_PLOT_OUTPUT_DIR. If it is not specified, plots will be sent to model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/plots (relative to **OUTPUT_BASE**). - -############################################################################## -# Keywords -# -------- -# -# sphinx_gallery_thumbnail_path = '_static/s2s-OMI_phase_diagram.png' -# -# .. note:: `XXXX`, `S2SAppUseCase `_ -# `RegridDataPlaneUseCase `_, -# `PCPCombineUseCase `_ - - -# -# - -############################################################################## -# Datasets -# -------- -# -# * Forecast dataset: GFS Model Outgoing Longwave Radiation, 850 hPa wind and 200 hPa wind -# * Observation dataset: ERA Reanlaysis Outgoing Longwave Radiation, 850 hPa wind and 200 hPa wind - -############################################################################## -# External Dependencies -# --------------------- -# -# You will need to use a version of Python 3.6+ that has the following packages installed:: -# -# * numpy -# * netCDF4 -# * datetime -# * xarray -# * matplotlib -# * scipy -# * pandas -# -# If the version of Python used to compile MET did not have these libraries at the time of compilation, you will need to add these packages or create a new Python environment with these packages. -# -# If this is the case, you will need to set the MET_PYTHON_EXE environment variable to the path of the version of Python you want to use. If you want this version of Python to only apply to this use case, set it in the [user_env_vars] section of a METplus configuration file.: -# -# [user_env_vars] -# MET_PYTHON_EXE = /path/to/python/with/required/packages/bin/python -# - -############################################################################## -# METplus Components -# ------------------ -# -# This use case runs the RMM driver which computes RMM and creates a phase diagram, time series, and EOF plot. Inputs to the RMM driver include netCDF files that are in MET's netCDF version. In addition, a text file containing the listing of these input netCDF files for OLR, u850 and u200 is required. Some optional pre-processing steps include using regrid_data_plane to either regrid your data or cut the domain t0 20N - 20S. -# - -############################################################################## -# METplus Workflow -# ---------------- -# The RMM driver script python code is run for each lead time on the forecast and observations data. This example loops by valid time for the model pre-processing, and valid time for the other steps. This version is set to only process the RMM calculation, omitting the regridding, and anomaly caluclation, and creation of the text file listing for OLR, u850, and u200 pre-processing steps. However, the configurations for pre-processing are available for user reference. -# - -############################################################################## -# METplus Configuration -# --------------------- -# -# METplus first loads all of the configuration files found in parm/metplus_config, -# then it loads any configuration files passed to METplus via the command line -# i.e. parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf. -# The file UserScript_fcstGFS_obsERA_RMM/RMM_driver.py runs the python program and -# UserScript_fcstGFS_obsERA_RMM.conf sets the variables for all steps of the RMM use case. -# -# .. highlight:: bash -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf - -############################################################################## -# MET Configuration -# --------------------- -# -# METplus sets environment variables based on the values in the METplus configuration file. -# These variables are referenced in the MET configuration file. **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** If there is a setting in the MET configuration file that is not controlled by an environment variable, you can add additional environment variables to be set only within the METplus environment using the [user_env_vars] section of the METplus configuration files. See the 'User Defined Config' section on the 'System Configuration' page of the METplus User's Guide for more information. -# -# - -############################################################################## -# Python Scripts -# ---------------- -# -# The RMM driver script orchestrates the calculation of the MJO indices and -# the generation of three RMM plots: -# parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/RMM_driver.py: -# -# .. highlight:: python -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/RMM_driver.py -# - -############################################################################## -# Running METplus -# --------------- -# -# This use case is run in the following ways: -# -# 1) Passing in UserScript_fcstGFS_obsERA_RMM.conf then a user-specific system configuration file:: -# -# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf -c /path/to/user_system.conf -# -# 2) Modifying the configurations in parm/metplus_config, then passing in UserScript_fcstGFS_obsERA_RMM.py:: -# -# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf -# -# The following variables must be set correctly: -# -# * **INPUT_BASE** - Path to directory where sample data tarballs are unpacked (See Datasets section to obtain tarballs). This is not required to run METplus, but it is required to run the examples in parm/use_cases -# * **OUTPUT_BASE** - Path where METplus output will be written. This must be in a location where you have write permissions -# * **MET_INSTALL_DIR** - Path to location where MET is installed locally -# -# Example User Configuration File:: -# -# [dir] -# INPUT_BASE = /path/to/sample/input/data -# OUTPUT_BASE = /path/to/output/dir -# MET_INSTALL_DIR = /path/to/met-X.Y -# - -############################################################################## -# Expected Output -# --------------- -# -# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. Output for this use case will be found in model_applications/s2s/UserScript_fcstGFS_obsERA_RMM. This may include the regridded data and daily averaged files. In addition, three plots will be generated, a phase diagram, time series, and EOF plot, and the output location can be specified as RMM_PLOT_OUTPUT_DIR. If it is not specified, plots will be sent to model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/plots (relative to **OUTPUT_BASE**). -# - -############################################################################## -# Keywords -# -------- -# -# sphinx_gallery_thumbnail_path = '_static/s2s-RMM_time_series.png' -# -# .. note:: `XXXX`, `S2SAppUseCase `_, -# `NetCDFFileUseCase ` -# `RegridDataPlaneUseCase `_, -# `PCPCombineUseCase `__ diff --git a/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.py b/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.py new file mode 100644 index 0000000000..90450b8134 --- /dev/null +++ b/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.py @@ -0,0 +1,141 @@ +""" +UserScript: Make OMI plot from calculated MJO indices +=========================================================================== + +model_applications/ +s2s/ +UserScript_obsERA_obsOnly_OMI.py + +""" + +############################################################################## +# Scientific Objective +# -------------------- +# +# To use Outgoing Longwave Radiation (OLR) to compute the OLR based MJO Index (OMI). Specifically, OMI is computed using OLR data between 20N and 20S. The OLR data are then projected onto Empirical Orthogonal Function (EOF) data that is computed for each day of the year, latitude, and longitude. The OLR is then filtered for 20 - 96 days, and regressed onto the daily EOFs. Finally, it's normalized and these normalized components are plotted on a phase diagram. +# + +############################################################################## +# Datasets +# -------- +# +# * Forecast dataset: None +# * Observation dataset: ERA Reanlaysis Outgoing Longwave Radiation. + +############################################################################## +# External Dependencies +# --------------------- +# +# You will need to use a version of Python 3.6+ that has the following packages installed:: +# +# * numpy +# * netCDF4 +# * datetime +# * xarray +# * matplotlib +# * scipy +# * pandas +# +# If the version of Python used to compile MET did not have these libraries at the time of compilation, you will need to add these packages or create a new Python environment with these packages. +# +# If this is the case, you will need to set the MET_PYTHON_EXE environment variable to the path of the version of Python you want to use. If you want this version of Python to only apply to this use case, set it in the [user_env_vars] section of a METplus configuration file.: +# +# [user_env_vars] +# MET_PYTHON_EXE = /path/to/python/with/required/packages/bin/python +# + +############################################################################## +# METplus Components +# ------------------ +# +# This use case runs the OMI driver which computes OMI and creates a phase diagram. Inputs to the OMI driver include netCDF files that are in MET's netCDF version. In addition, a txt file containing the listing of these input netCDF files is required, as well as text file listings of the EOF1 and EOF2 files. These text files can be generated using the USER_SCRIPT_INPUT_TEMPLATES in the [create_eof_filelist] and [script_omi] sections. Some optional pre-processing steps include using regrid_data_plane to either regrid your data or cut the domain to 20N - 20S. +# + +############################################################################## +# METplus Workflow +# ---------------- +# +# The OMI driver script python code is run for each lead time on the forecast and observations data. This example loops by valid time for the model pre-processing, and valid time for the other steps. This version is set to only process the OMI calculation and creating a text file listing of the EOF files, omitting the creation of daily means for the model and the regridding pre-processing steps. However, the configurations for pre-processing are available for user reference. + +############################################################################## +# METplus Configuration +# --------------------- +# +# METplus first loads all of the configuration files found in parm/metplus_config, +# then it loads any configuration files passed to METplus via the command line +# i.e. parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf. +# The file UserScript_obsERA_obsOnly_OMI/OMI_driver.py runs the python program and +# UserScript_fcstGFS_obsERA_OMI.conf sets the variables for all steps of the OMI use case. +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf + +############################################################################## +# MET Configuration +# --------------------- +# +# METplus sets environment variables based on the values in the METplus configuration file. +# These variables are referenced in the MET configuration file. **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** If there is a setting in the MET configuration file that is not controlled by an environment variable, you can add additional environment variables to be set only within the METplus environment using the [user_env_vars] section of the METplus configuration files. See the 'User Defined Config' section on the 'System Configuration' page of the METplus User's Guide for more information. +# +# + +############################################################################## +# Python Scripts +# ---------------- +# +# The OMI driver script orchestrates the calculation of the MJO indices and +# the generation of a phase diagram OMI plot: +# parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py: +# +# .. highlight:: python +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py +# + +############################################################################## +# Running METplus +# --------------- +# +# This use case is run in the following ways: +# +# 1) Passing in UserScript_obsERA_obsOnly_OMI.conf then a user-specific system configuration file:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf -c /path/to/user_system.conf +# +# 2) Modifying the configurations in parm/metplus_config, then passing in UserScript_obsERA_obsOnly_OMI.py:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf +# +# The following variables must be set correctly: +# +# * **INPUT_BASE** - Path to directory where sample data tarballs are unpacked (See Datasets section to obtain tarballs). This is not required to run METplus, but it is required to run the examples in parm/use_cases +# * **OUTPUT_BASE** - Path where METplus output will be written. This must be in a location where you have write permissions +# * **MET_INSTALL_DIR** - Path to location where MET is installed locally +# +# Example User Configuration File:: +# +# [dir] +# INPUT_BASE = /path/to/sample/input/data +# OUTPUT_BASE = /path/to/output/dir +# MET_INSTALL_DIR = /path/to/met-X.Y +# + +############################################################################## +# Expected Output +# --------------- +# +# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. Output for this use case will be found in model_applications/s2s/UserScript_obsERA_obsOnly_OMI. This may include the regridded data and daily averaged files. In addition, the phase diagram plots will be generated and the output location can be specified as OMI_PLOT_OUTPUT_DIR. If it is not specified, plots will be sent to model_applications/s2s/UserScript_obsERA_obsOnly_OMI/plots (relative to **OUTPUT_BASE**). + +############################################################################## +# Keywords +# -------- +# +# .. note:: +# +# * S2SAppUseCase +# * RegridDataPlaneUseCase +# * PCPCombineUseCase +# +# Navigate to :ref:`quick-search` to discover other similar use cases. +# +# sphinx_gallery_thumbnail_path = '_static/s2s-OMI_phase_diagram.png' +# \ No newline at end of file diff --git a/docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.py b/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.py similarity index 82% rename from docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.py rename to docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.py index 9a9e2996e3..3266d628dd 100644 --- a/docs/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.py +++ b/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.py @@ -4,7 +4,7 @@ model_applications/ s2s/ -UserScript_fcstGFS_obsERA_PhaseDiagram.py +UserScript_obsERA_obsOnly_PhaseDiagram.py """ @@ -63,13 +63,13 @@ # # METplus first loads all of the configuration files found in parm/metplus_config, # then it loads any configuration files passed to METplus via the command line -# i.e. parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf. -# The file UserScript_fcstGFS_obsERA_PhaseDiagram/PhaseDiagram_driver.py runs the python -# program and UserScript_fcstGFS_obsERA_PhaseDiagram.conf sets the variables for all steps +# i.e. parm/use_cases/model_applications/s2s/UserScript_obsERA_obsERA_OMI.conf. +# The file UserScript_obsERA_obsOnly_PhaseDiagram/PhaseDiagram_driver.py runs the python +# program and UserScript_obsERA_obsOnly_PhaseDiagram.conf sets the variables for all steps # of the use case. # # .. highlight:: bash -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf ############################################################################## # MET Configuration @@ -84,11 +84,11 @@ # ---------------- # # The phase diagram driver script orchestrates the generation of a phase diagram plot: -# parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/PhaseDiagram_driver.py: +# parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/PhaseDiagram_driver.py: # # .. highlight:: python -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/PhaseDiagram_driver.py -# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/save_input_files_txt.py +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/PhaseDiagram_driver.py +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/save_input_files_txt.py # ############################################################################## @@ -97,13 +97,13 @@ # # This use case is run in the following ways: # -# 1) Passing in UserScript_fcstGFS_obsERA_OMI.conf then a user-specific system configuration file:: +# 1) Passing in UserScript_obsERA_obsOnly_PhaseDiagram.conf then a user-specific system configuration file:: # -# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf -c /path/to/user_system.conf +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf -c /path/to/user_system.conf # -# 2) Modifying the configurations in parm/metplus_config, then passing in UserScript_fcstGFS_obsERA_PhaseDiagram.py:: +# 2) Modifying the configurations in parm/metplus_config, then passing in UserScript_obsERA_obsOnly_PhaseDiagram.py:: # -# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf # # The following variables must be set correctly: # @@ -123,12 +123,18 @@ # Expected Output # --------------- # -# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. Output for this use case will be found in model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram. This may include the regridded data and daily averaged files. In addition, the phase diagram plots will be generated and the output location can be specified as OMI_PLOT_OUTPUT_DIR. If it is not specified, plots will be sent to model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/plots (relative to **OUTPUT_BASE**). +# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. Output for this use case will be found in model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram. This may include the regridded data and daily averaged files. In addition, the phase diagram plots will be generated and the output location can be specified as PHASE_DIAGRAM_PLOT_OUTPUT_DIR. If it is not specified, plots will be sent to model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/plots (relative to **OUTPUT_BASE**). ############################################################################## # Keywords # -------- # -# sphinx_gallery_thumbnail_path = '_static/s2s-PhaseDiagram.png' # -# .. note:: `XXXX`, `S2SAppUseCase `_ +# .. note:: +# +# * S2SAppUseCase +# +# Navigate to :ref:`quick-search` to discover other similar use cases. +# +# sphinx_gallery_thumbnail_path = '_static/s2s-PhaseDiagram.png' +# \ No newline at end of file diff --git a/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.py b/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.py new file mode 100644 index 0000000000..6c4f3e5c6c --- /dev/null +++ b/docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.py @@ -0,0 +1,147 @@ +""" +UserScript: Make RMM plots from calculated MJO indices +=========================================================================== + +model_applications/ +s2s/ +UserScript_obsERA_obsOnly_RMM.py + +""" + +############################################################################## +# Scientific Objective +# -------------------- +# +# To compute the Real-time Multivariate MJO Index (RMM) using Outgoing Longwave Radiation (OLR), 850 hPa wind (U850), and 200 hPa wind (U200). Specifically, RMM is computed using OLR, U850, and U200 data between 15N and 15S. Anomalies of OLR, U850, and U200 are created using a harmonic analysis, 120 day day mean removed, and the data are normalized by normalization factors (generally the square root of the average variance) The anomalies are projected onto Empirical Orthogonal Function (EOF) data. The OLR is then filtered for 20 - 96 days, and regressed onto the daily EOFs. Finally, it's normalized and these normalized components are plotted on a phase diagram and timeseries plot. +# + +############################################################################## +# Datasets +# -------- +# +# * Forecast dataset: None +# * Observation dataset: ERA Reanlaysis Outgoing Longwave Radiation, 850 hPa wind and 200 hPa wind + +############################################################################## +# External Dependencies +# --------------------- +# +# You will need to use a version of Python 3.6+ that has the following packages installed:: +# +# * numpy +# * netCDF4 +# * datetime +# * xarray +# * matplotlib +# * scipy +# * pandas +# +# If the version of Python used to compile MET did not have these libraries at the time of compilation, you will need to add these packages or create a new Python environment with these packages. +# +# If this is the case, you will need to set the MET_PYTHON_EXE environment variable to the path of the version of Python you want to use. If you want this version of Python to only apply to this use case, set it in the [user_env_vars] section of a METplus configuration file.: +# +# [user_env_vars] +# MET_PYTHON_EXE = /path/to/python/with/required/packages/bin/python +# + +############################################################################## +# METplus Components +# ------------------ +# +# This use case runs the RMM driver which computes first computes anomalies of outgoing longwave raidation, 850 hPa wind and 200 hPa wind. Then, it regrids the data to 15S to 15N. Next, RMM is computed and a phase diagram, time series, and EOF plot are created. Inputs to the RMM driver include netCDF files that are in MET's netCDF version. In addition, a text file containing the listing of these input netCDF files for OLR, u850 and u200 is required. Some optional pre-processing steps include using pcp_combine to compute daily means and the mean daily annual cycle for the data. +# + +############################################################################## +# METplus Workflow +# ---------------- +# The RMM driver script python code is run for each lead time on the forecast and observations data. This example loops by valid time for the model pre-processing, and valid time for the other steps. This version is set to only process the creation of anomalies, regridding, and RMM calculation, omitting the caluclation of daily means and the mean daily annucal cycle pre-processing steps. However, the configurations for pre-processing are available for user reference. +# + +############################################################################## +# METplus Configuration +# --------------------- +# +# METplus first loads all of the configuration files found in parm/metplus_config, +# then it loads any configuration files passed to METplus via the command line +# i.e. parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf. +# The file UserScript_obsERA_obsOnly_RMM/RMM_driver.py runs the python program and +# UserScript_obsERA_obsOnly_RMM.conf sets the variables for all steps of the RMM use case. +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf + +############################################################################## +# MET Configuration +# --------------------- +# +# METplus sets environment variables based on the values in the METplus configuration file. +# These variables are referenced in the MET configuration file. **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** If there is a setting in the MET configuration file that is not controlled by an environment variable, you can add additional environment variables to be set only within the METplus environment using the [user_env_vars] section of the METplus configuration files. See the 'User Defined Config' section on the 'System Configuration' page of the METplus User's Guide for more information. +# +# + +############################################################################## +# Python Scripts +# ---------------- +# +# The RMM driver script orchestrates the calculation of the MJO indices and +# the generation of three RMM plots: +# parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/RMM_driver.py: +# The harmonic anomalies script creates anomalies of input data using a harmonic analysis: +# parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py +# +# .. highlight:: python +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/RMM_driver.py +# .. literalinclude:: ../../../../parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py +# + +############################################################################## +# Running METplus +# --------------- +# +# This use case is run in the following ways: +# +# 1) Passing in UserScript_obsERA_obsOnly_RMM.conf then a user-specific system configuration file:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf -c /path/to/user_system.conf +# +# 2) Modifying the configurations in parm/metplus_config, then passing in UserScript_obsERA_obsOnly_RMM.py:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf +# +# The following variables must be set correctly: +# +# * **INPUT_BASE** - Path to directory where sample data tarballs are unpacked (See Datasets section to obtain tarballs). This is not required to run METplus, but it is required to run the examples in parm/use_cases +# * **OUTPUT_BASE** - Path where METplus output will be written. This must be in a location where you have write permissions +# * **MET_INSTALL_DIR** - Path to location where MET is installed locally +# +# Example User Configuration File:: +# +# [dir] +# INPUT_BASE = /path/to/sample/input/data +# OUTPUT_BASE = /path/to/output/dir +# MET_INSTALL_DIR = /path/to/met-X.Y +# + +############################################################################## +# Expected Output +# --------------- +# +# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. Output for this use case will be found in model_applications/s2s/UserScript_obsERA_obsOnly_RMM. This may include the regridded data and daily averaged files. In addition, three plots will be generated, a phase diagram, time series, and EOF plot, and the output location can be specified as RMM_PLOT_OUTPUT_DIR. If it is not specified, plots will be sent to model_applications/s2s/UserScript_obsERA_obsOnly_RMM/plots (relative to **OUTPUT_BASE**). +# + +############################################################################## +# Keywords +# -------- +# +# +# .. note:: +# +# * S2SAppUseCase +# * NetCDFFileUseCase +# * RegridDataPlaneUseCase +# * PCPCombineUseCase +# +# Navigate to :ref:`quick-search` to discover other similar use cases. +# +# sphinx_gallery_thumbnail_path = '_static/s2s-RMM_time_series.png' +# \ No newline at end of file diff --git a/internal_tests/use_cases/all_use_cases.txt b/internal_tests/use_cases/all_use_cases.txt index 2d196b9b67..b6cd0a3af5 100644 --- a/internal_tests/use_cases/all_use_cases.txt +++ b/internal_tests/use_cases/all_use_cases.txt @@ -123,10 +123,11 @@ Category: s2s 4::TCGen_fcstGFSO_obsBDECKS_GDF_TDF:: model_applications/s2s/TCGen_fcstGFSO_obsBDECKS_GDF_TDF.conf:: metplotpy_env,cartopy,metplus 5::UserScript_obsPrecip_obsOnly_Hovmoeller:: model_applications/s2s/UserScript_obsPrecip_obsOnly_Hovmoeller.conf:: metplotpy_env,cartopy 6:: UserScript_obsPrecip_obsOnly_CrossSpectraPlot:: model_applications/s2s/UserScript_obsPrecip_obsOnly_CrossSpectraPlot.conf:: spacetime_env -7:: UserScript_fcstGFS_obsERA_PhaseDiagram:: model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf:: spacetime_env +7:: UserScript_obsERA_obsOnly_PhaseDiagram:: model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf:: spacetime_env 8:: UserScript_fcstGFS_obsERA_OMI:: model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf:: spacetime_env, metdatadb -9:: UserScript_fcstGFS_obsERA_RMM:: model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf:: spacetime_env, metdatadb -10::UserScript_fcstGFS_obsERA_WeatherRegime:: model_applications/s2s/UserScript_fcstGFS_obsERA_WeatherRegime.conf:: weatherregime_env,cartopy,metplus +9:: UserScript_obsERA_obsOnly_OMI:: model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf:: spacetime_env, metdatadb +10:: UserScript_obsERA_obsOnly_RMM:: model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf:: spacetime_env, metdatadb +11:: UserScript_fcstGFS_obsERA_WeatherRegime:: model_applications/s2s/UserScript_fcstGFS_obsERA_WeatherRegime.conf:: weatherregime_env,cartopy,metplus Category: space_weather 0::GridStat_fcstGloTEC_obsGloTEC_vx7:: model_applications/space_weather/GridStat_fcstGloTEC_obsGloTEC_vx7.conf diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf b/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf index 628ff33271..8ca46eba79 100644 --- a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf +++ b/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI.conf @@ -1,7 +1,7 @@ # OMI UserScript wrapper [config] # All steps, including pre-processing: -#PROCESS_LIST = RegridDataPlane(regrid_obs_olr), UserScript(create_eof_filelist), UserScript(script_omi) +#PROCESS_LIST = PcpCombine(daily_mean_fcst), RegridDataPlane(regrid_obs_olr), RegridDataPlane(regrid_fcst_olr), UserScript(create_eof_filelist), UserScript(script_omi) # Finding EOF files and OMI Analysis script for the observations PROCESS_LIST = UserScript(create_eof_filelist), UserScript(script_omi) @@ -19,10 +19,10 @@ LOOP_BY = VALID VALID_TIME_FMT = %Y%m%d%H # Start time for METplus run -VALID_BEG = 1979010100 +VALID_BEG = 2017010100 # End time for METplus run -VALID_END = 2012123000 +VALID_END = 2018123100 # Increment between METplus runs in seconds. Must be >= 60 VALID_INCREMENT = 86400 @@ -47,7 +47,7 @@ CONFIG_DIR={PARM_BASE}/use_cases/model_applications/s2s # Run the obs for these cases OBS_RUN = True -FCST_RUN = False +FCST_RUN = True # Mask to use for regridding REGRID_DATA_PLANE_VERIF_GRID = latlon 144 17 -20 0 2.5 2.5 @@ -59,12 +59,36 @@ REGRID_DATA_PLANE_METHOD = NEAREST REGRID_DATA_PLANE_WIDTH = 1 # Input and Output Directories for the OBS OLR Files and output text file containing the file list -OBS_OLR_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/ERA +OBS_OLR_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/ERA/Regrid OBS_OLR_INPUT_TEMPLATE = OLR_{valid?fmt=%Y%m%d}.nc +# Input and Output Directories for the OBS OLR Files and output text file containing the file list +FCST_OLR_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/GFS/Regrid +FCST_OLR_INPUT_TEMPLATE = OLR_{valid?fmt=%Y%m%d}.nc + + +# Configurations for pcp_combine: Create daily means for the GFS +[daily_mean_fcst] +# run pcp_combine on obs data +FCST_PCP_COMBINE_RUN = {FCST_RUN} + +# method to run pcp_combine on forecast data +# Options are ADD, SUM, SUBTRACT, DERIVE, and USER_DEFINED +FCST_PCP_COMBINE_METHOD = USER_DEFINED + +FCST_PCP_COMBINE_COMMAND = -derive mean {FCST_PCP_COMBINE_INPUT_DIR}/{valid?fmt=%Y}/{valid?fmt=%Y%m%d}/gfs.0p25.{valid?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=86400}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{valid?fmt=%Y}/{valid?fmt=%Y%m%d}/gfs.0p25.{valid?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=75600}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{valid?fmt=%Y}/{valid?fmt=%Y%m%d}/gfs.0p25.{valid?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=64800}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{init?fmt=%Y}/{init?fmt=%Y%m%d}/gfs.0p25.{init?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=54000}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{init?fmt=%Y}/{init?fmt=%Y%m%d}/gfs.0p25.{init?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=43200}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{init?fmt=%Y}/{init?fmt=%Y%m%d}/gfs.0p25.{init?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=32400}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{init?fmt=%Y}/{init?fmt=%Y%m%d}/gfs.0p25.{init?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=21600}.grib2 {FCST_PCP_COMBINE_INPUT_DIR}/{init?fmt=%Y}/{init?fmt=%Y%m%d}/gfs.0p25.{init?fmt=%Y%m%d%H}.f{lead?fmt=%HHH?shift=10800}.grib2 -field 'name="ULWRF"; level="L0"; set_attr_valid = "{valid?fmt=%Y%m%d_%H%M%S}"; GRIB2_ipdtmpl_index = 9; GRIB2_ipdtmpl_val = 8;' + +FCST_PCP_COMBINE_INPUT_DIR = /gpfs/fs1/collections/rda/data/ds084.1 +FCST_PCP_COMBINE_INPUT_TEMPLATE = {valid?fmt=%Y%m}/gfs.0p25.{init?fmt=%y%m%d%H}.f{lead?fmt=%HHH}.grib2 + +FCST_PCP_COMBINE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_OMI/GFS/daily_mean +FCST_PCP_COMBINE_OUTPUT_TEMPLATE = GFS_mean_{valid?fmt=%Y%m%d}.nc -# Configurations for regrid_data_plane: Regrid OLR to -20 to 20 latitude + +# Configurations for regrid_data_plane: Regrid ERA OLR to -20 to 20 latitude [regrid_obs_olr] +LEAD_SEQ = 0 + # Run regrid_data_plane on forecast data OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} @@ -84,7 +108,7 @@ OBS_REGRID_DATA_PLANE_VAR1_OPTIONS = file_type=NETCDF_NCCF; censor_thresh=eq-999 OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = olr # input and output data directories for each application in PROCESS_LIST -OBS_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI +OBS_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/ERA/daily_mean OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OBS_OLR_INPUT_DIR} # format of filenames @@ -93,6 +117,34 @@ OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = olr.1x.7920.nc OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = {OBS_OLR_INPUT_TEMPLATE} +# Configurations for regrid_data_plane: Regrid GFS OLR to -20 to 20 latitude +[regrid_fcst_olr] +# Run regrid_data_plane on forecast data +FCST_REGRID_DATA_PLANE_RUN = {FCST_RUN} + +# If true, process each field individually and write a file for each +# If false, run once per run time passing in all fields specified +REGRID_DATA_PLANE_ONCE_PER_FIELD = False + +# Name of input field to process +FCST_REGRID_DATA_PLANE_VAR1_NAME = ULWRF_L0_mean + +# Level of input field to process +FCST_REGRID_DATA_PLANE_VAR1_LEVELS = "(*,*)" + +# Name of output field to create +FCST_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = olr + +# input and output data directories for each application in PROCESS_LIST +FCST_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/GFS/daily_mean +FCST_REGRID_DATA_PLANE_OUTPUT_DIR = {FCST_OLR_INPUT_DIR} + +# format of filenames +# Input ERA Interim +FCST_REGRID_DATA_PLANE_INPUT_TEMPLATE = GFS_mean_{valid?fmt=%Y%m%d}.nc +FCST_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = {FCST_OLR_INPUT_TEMPLATE} + + # Create the EOF filelists [create_eof_filelist] # Find the files for each time to create the time list @@ -133,11 +185,13 @@ OBS_PER_DAY = 1 OMI_PLOT_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_OMI/plots # Phase Plot start date, end date, output name, and format -PHASE_PLOT_TIME_BEG = 2012010100 -PHASE_PLOT_TIME_END = 2012033000 +PHASE_PLOT_TIME_BEG = 2017010100 +PHASE_PLOT_TIME_END = 2017033100 PHASE_PLOT_TIME_FMT = {VALID_TIME_FMT} OBS_PHASE_PLOT_OUTPUT_NAME = obs_OMI_comp_phase -OBS_PHASE_PLOT_OUTPUT_FORMAT = png +OBS_PHASE_PLOT_OUTPUT_FORMAT = png +FCST_PHASE_PLOT_OUTPUT_NAME = fcst_OMI_comp_phase +FCST_PHASE_PLOT_OUTPUT_FORMAT = png # Configurations for UserScript: Run the RMM Analysis driver @@ -146,12 +200,12 @@ OBS_PHASE_PLOT_OUTPUT_FORMAT = png USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD ## Template of OLR filenames to input to the user-script -USER_SCRIPT_INPUT_TEMPLATE = {OBS_OLR_INPUT_DIR}/{OBS_OLR_INPUT_TEMPLATE} +USER_SCRIPT_INPUT_TEMPLATE = {OBS_OLR_INPUT_DIR}/{OBS_OLR_INPUT_TEMPLATE},{FCST_OLR_INPUT_DIR}/{FCST_OLR_INPUT_TEMPLATE} ## Name of the file containing the listing of OLR input files ## The options are OBS_OLR_INPUT and FCST_OLR_INPUT ## *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE -USER_SCRIPT_INPUT_TEMPLATE_LABELS = OBS_OLR_INPUT +USER_SCRIPT_INPUT_TEMPLATE_LABELS = OBS_OLR_INPUT,FCST_OLR_INPUT # Command to run the user script with input configuration file USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py b/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py index 1c63b88104..9e1ab61d05 100755 --- a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py +++ b/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_OMI/OMI_driver.py @@ -84,6 +84,7 @@ def run_omi_steps(inlabel, olr_filetxt, spd, EOF1, EOF2, oplot_dir): # Get the output name and format for the PC plase diagram phase_plot_name = os.path.join(oplot_dir,os.environ.get(inlabel+'_PHASE_PLOT_OUTPUT_NAME',inlabel+'_OMI_comp_phase')) + print(phase_plot_name) phase_plot_format = os.environ.get(inlabel+'_PHASE_PLOT_OUTPUT_FORMAT','png') # plot the PC phase diagram @@ -128,7 +129,7 @@ def main(): # Determine if doing forecast or obs run_obs_omi = os.environ.get('RUN_OBS','False').lower() - run_fcst_omi = os.environ.get('FCST_RUN_FCST', 'False').lower() + run_fcst_omi = os.environ.get('RUN_FCST', 'False').lower() # Run the steps to compute OMM # Observations diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf b/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf deleted file mode 100644 index 2eb51d1608..0000000000 --- a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM.conf +++ /dev/null @@ -1,214 +0,0 @@ -# RMM UserScript wrapper -[config] -# All steps, including pre-processing: -#PROCESS_LIST = RegridDataPlane(regrid_obs_olr), RegridDataPlane(regrid_obs_u850), RegridDataPlane(regrid_obs_u200), UserScript(script_rmm) -# Only RMM Analysis script for the observations -PROCESS_LIST = UserScript(script_rmm) - -# time looping - options are INIT, VALID, RETRO, and REALTIME -# If set to INIT or RETRO: -# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set -# If set to VALID or REALTIME: -# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set -LOOP_BY = VALID - -# Format of VALID_BEG and VALID_END using % items -# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. -# see www.strftime.org for more information -# %Y%m%d%H expands to YYYYMMDDHH -VALID_TIME_FMT = %Y%m%d%H - -# Start time for METplus run -VALID_BEG = 2000010100 - -# End time for METplus run -VALID_END = 2002123000 - -# Increment between METplus runs in seconds. Must be >= 60 -VALID_INCREMENT = 86400 - -# List of forecast leads to process for each run time (init or valid) -# In hours if units are not specified -# If unset, defaults to 0 (don't loop through forecast leads) -LEAD_SEQ = 0 - -# Order of loops to process data - Options are times, processes -# Not relevant if only one item is in the PROCESS_LIST -# times = run all wrappers in the PROCESS_LIST for a single run time, then -# increment the run time and run all wrappers again until all times have -# been evaluated. -# processes = run the first wrapper in the PROCESS_LIST for all times -# specified, then repeat for the next item in the PROCESS_LIST until all -# wrappers have been run -LOOP_ORDER = processes - -# location of configuration files used by MET applications -CONFIG_DIR={PARM_BASE}/use_cases/model_applications/s2s - -# Run the obs for these cases -OBS_RUN = True -FCST_RUN = False - -# Mask to use for regridding -REGRID_DATA_PLANE_VERIF_GRID = latlon 144 13 -15 0 2.5 2.5 - -# Method to run regrid_data_plane, not setting this will default to NEAREST -REGRID_DATA_PLANE_METHOD = NEAREST - -# Regridding width used in regrid_data_plane, not setting this will default to 1 -REGRID_DATA_PLANE_WIDTH = 1 - - -# Configurations for regrid_data_plane: Regrid OLR to -15 to 15 latitude -[regrid_obs_olr] -# Run regrid_data_plane on forecast data -OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} - -# If true, process each field individually and write a file for each -# If false, run once per run time passing in all fields specified -OBS_DATA_PLANE_ONCE_PER_FIELD = False - -# Name of input field to process -OBS_REGRID_DATA_PLANE_VAR1_NAME = olr - -# Level of input field to process -OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "({valid?fmt=%Y%m%d_%H%M%S},*,*)" - -OBS_REGRID_DATA_PLANE_VAR1_OPTIONS = file_type=NETCDF_NCCF; censor_thresh=eq-999.0; censor_val=-9999.0; - -# Name of output field to create -OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = olr - -# input and output data directories for each application in PROCESS_LIST -OBS_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM -OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_RMM/ERA - -# format of filenames -# Input ERA Interim -OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = olr.1x.7920.anom7901.nc -OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = OLR_{valid?fmt=%Y%m%d}.nc - - -# Configurations for regrid_data_plane: Regrid u850 to -15 to 15 latitude -[regrid_obs_u850] -# Run regrid_data_plane on forecast data -OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} - -# If true, process each field individually and write a file for each -# If false, run once per run time passing in all fields specified -OBS_DATA_PLANE_ONCE_PER_FIELD = False - -# Name of input field to process -OBS_REGRID_DATA_PLANE_VAR1_NAME = uwnd - -# Level of input field to process -OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "({valid?fmt=%Y%m%d_%H%M%S},*,*)" - -OBS_REGRID_DATA_PLANE_VAR1_OPTIONS = file_type=NETCDF_NCCF; censor_thresh=eq-999.0; censor_val=-9999.0; - -# Name of output field to create -OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = uwnd850 - -# input and output data directories for each application in PROCESS_LIST -OBS_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM -OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_RMM/ERA - -# format of filenames -# Input ERA Interim -OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = uwnd.erai.an.2p5.850.daily.anom7901.nc -OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = u850_{valid?fmt=%Y%m%d}.nc - - -# Configurations for regrid_data_plane: Regrid u200 to -15 to 15 latitude -[regrid_obs_u200] -# Run regrid_data_plane on forecast data -OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} - -# If true, process each field individually and write a file for each -# If false, run once per run time passing in all fields specified -OBS_DATA_PLANE_ONCE_PER_FIELD = False - -# Name of input field to process -OBS_REGRID_DATA_PLANE_VAR1_NAME = uwnd - -# Level of input field to process -OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "({valid?fmt=%Y%m%d_%H%M%S},*,*)" - -OBS_REGRID_DATA_PLANE_VAR1_OPTIONS = file_type=NETCDF_NCCF; censor_thresh=eq-999.0; censor_val=-9999.0; - -# Name of output field to create -OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = uwnd200 - -# input and output data directories for each application in PROCESS_LIST -OBS_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM -OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_RMM/ERA - -# format of filenames -# Input ERA Interim -OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = uwnd.erai.an.2p5.200.daily.anom7901.nc -OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = u200_{valid?fmt=%Y%m%d}.nc - - -# Configurations for the RMM analysis script -[user_env_vars] -# Whether to Run the model or obs -RUN_OBS = {OBS_RUN} -RUN_FCST = {FCST_RUN} - -# Make OUTPUT_BASE Available to the script -SCRIPT_OUTPUT_BASE = {OUTPUT_BASE} - -# Number of obs per day -OBS_PER_DAY = 1 - -# EOF Filename -OLR_EOF_INPUT_TEXTFILE = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/EOF/rmm_olr_eofs.txt -U850_EOF_INPUT_TEXTFILE = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/EOF/rmm_u850_eofs.txt -U200_EOF_INPUT_TEXTFILE = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/EOF/rmm_u200_eofs.txt - -# Normalization factors for RMM -RMM_OLR_NORM = 15.11623 -RMM_U850_NORM = 1.81355 -RMM_U200_NORM = 4.80978 -PC1_NORM = 8.618352504159244 -PC2_NORM = 8.40736449709697 - -# Output Directory for the plots -# If not set, it this will default to {OUTPUT_BASE}/plots -RMM_PLOT_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_RMM/plots - -# EOF plot information -EOF_PLOT_OUTPUT_NAME = RMM_EOFs -EOF_PLOT_OUTPUT_FORMAT = png - -# Phase Plot start date, end date, output name, and format -PHASE_PLOT_TIME_BEG = 2002010100 -PHASE_PLOT_TIME_END = 2002123000 -PHASE_PLOT_TIME_FMT = {VALID_TIME_FMT} -OBS_PHASE_PLOT_OUTPUT_NAME = obs_RMM_comp_phase -OBS_PHASE_PLOT_OUTPUT_FORMAT = png - -# Time Series Plot start date, end date, output name, and format -TIMESERIES_PLOT_TIME_BEG = 2002010100 -TIMESERIES_PLOT_TIME_END = 2002123000 -TIMESERIES_PLOT_TIME_FMT = {VALID_TIME_FMT} -OBS_TIMESERIES_PLOT_OUTPUT_NAME = obs_RMM_time_series -OBS_TIMESERIES_PLOT_OUTPUT_FORMAT = png - - -# Configurations for UserScript: Run the RMM Analysis driver -[script_rmm] -# list of strings to loop over for each run time. -# Run the user script once per lead -USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD - -# Template of filenames to input to the user-script -USER_SCRIPT_INPUT_TEMPLATE = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/ERA/OLR_{valid?fmt=%Y%m%d}.nc,{INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/ERA/u850_{valid?fmt=%Y%m%d}.nc,{INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/ERA/u200_{valid?fmt=%Y%m%d}.nc - -# Name of the file containing the listing of input files -# The options are OBS_OLR_INPUT, OBS_U850_INPUT, OBS_U200_INPUT, FCST_OLR_INPUT, FCST_U850_INPUT, and FCST_U200_INPUT -# *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE -USER_SCRIPT_INPUT_TEMPLATE_LABELS = OBS_OLR_INPUT,OBS_U850_INPUT, OBS_U200_INPUT - -# Command to run the user script with input configuration file -USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/RMM_driver.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf new file mode 100644 index 0000000000..fff56cde25 --- /dev/null +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI.conf @@ -0,0 +1,157 @@ +# OMI UserScript wrapper +[config] +# All steps, including pre-processing: +#PROCESS_LIST = RegridDataPlane(regrid_obs_olr), UserScript(create_eof_filelist), UserScript(script_omi) +# Finding EOF files and OMI Analysis script for the observations +PROCESS_LIST = UserScript(create_eof_filelist), UserScript(script_omi) + +# time looping - options are INIT, VALID, RETRO, and REALTIME +# If set to INIT or RETRO: +# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set +# If set to VALID or REALTIME: +# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set +LOOP_BY = VALID + +# Format of VALID_BEG and VALID_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d%H + +# Start time for METplus run +VALID_BEG = 1979010100 + +# End time for METplus run +VALID_END = 2012123000 + +# Increment between METplus runs in seconds. Must be >= 60 +VALID_INCREMENT = 86400 + +# List of forecast leads to process for each run time (init or valid) +# In hours if units are not specified +# If unset, defaults to 0 (don't loop through forecast leads) +LEAD_SEQ = 0 + +# Order of loops to process data - Options are times, processes +# Not relevant if only one item is in the PROCESS_LIST +# times = run all wrappers in the PROCESS_LIST for a single run time, then +# increment the run time and run all wrappers again until all times have +# been evaluated. +# processes = run the first wrapper in the PROCESS_LIST for all times +# specified, then repeat for the next item in the PROCESS_LIST until all +# wrappers have been run +LOOP_ORDER = processes + +# location of configuration files used by MET applications +CONFIG_DIR={PARM_BASE}/use_cases/model_applications/s2s + +# Run the obs for these cases +OBS_RUN = True +FCST_RUN = False + +# Mask to use for regridding +REGRID_DATA_PLANE_VERIF_GRID = latlon 144 17 -20 0 2.5 2.5 + +# Method to run regrid_data_plane, not setting this will default to NEAREST +REGRID_DATA_PLANE_METHOD = NEAREST + +# Regridding width used in regrid_data_plane, not setting this will default to 1 +REGRID_DATA_PLANE_WIDTH = 1 + +# Input and Output Directories for the OBS OLR Files and output text file containing the file list +OBS_OLR_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/ERA +OBS_OLR_INPUT_TEMPLATE = OLR_{valid?fmt=%Y%m%d}.nc + + +# Configurations for regrid_data_plane: Regrid OLR to -20 to 20 latitude +[regrid_obs_olr] +# Run regrid_data_plane on forecast data +OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} + +# If true, process each field individually and write a file for each +# If false, run once per run time passing in all fields specified +OBS_DATA_PLANE_ONCE_PER_FIELD = False + +# Name of input field to process +OBS_REGRID_DATA_PLANE_VAR1_NAME = olr + +# Level of input field to process +OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "({valid?fmt=%Y%m%d_%H%M%S},*,*)" + +OBS_REGRID_DATA_PLANE_VAR1_OPTIONS = file_type=NETCDF_NCCF; censor_thresh=eq-999.0; censor_val=-9999.0; + +# Name of output field to create +OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = olr + +# input and output data directories for each application in PROCESS_LIST +OBS_REGRID_DATA_PLANE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_OMI +OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OBS_OLR_INPUT_DIR} + +# format of filenames +# Input ERA Interim +OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = olr.1x.7920.nc +OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = {OBS_OLR_INPUT_TEMPLATE} + + +# Create the EOF filelists +[create_eof_filelist] +# Find the files for each time to create the time list +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE + +# Valid Begin and End Times for the EOF files +VALID_BEG = 2012010100 +VALID_END = 2012123100 + +# Find the EOF files for each time +# Filename templates for EOF1 and EOF2 +USER_SCRIPT_INPUT_TEMPLATE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/EOF/eof1/eof{valid?fmt=%j}.txt,{INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/EOF/eof2/eof{valid?fmt=%j}.txt + +# Name of the file containing the listing of input files +# The options are EOF1_INPUT and EOF2_INPUT +# *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE +USER_SCRIPT_INPUT_TEMPLATE_LABELS = EOF1_INPUT, EOF2_INPUT + +# Placeholder command just to build the file list +# This just states that it's building the file list +USER_SCRIPT_COMMAND = echo Populated file list for EOF1 and EOF2 Input + + +# Configurations for the OMI analysis script +[user_env_vars] +# Whether to Run the model or obs +RUN_OBS = {OBS_RUN} +RUN_FCST = {FCST_RUN} + +# Make OUTPUT_BASE Available to the script +SCRIPT_OUTPUT_BASE = {OUTPUT_BASE} + +# Number of obs per day +OBS_PER_DAY = 1 + +# Output Directory for the plots +# If not set, it this will default to {OUTPUT_BASE}/plots +OMI_PLOT_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_OMI/plots + +# Phase Plot start date, end date, output name, and format +PHASE_PLOT_TIME_BEG = 2012010100 +PHASE_PLOT_TIME_END = 2012033000 +PHASE_PLOT_TIME_FMT = {VALID_TIME_FMT} +OBS_PHASE_PLOT_OUTPUT_NAME = obs_OMI_comp_phase +OBS_PHASE_PLOT_OUTPUT_FORMAT = png + + +# Configurations for UserScript: Run the RMM Analysis driver +[script_omi] +# Run the script once per lead time +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD + +## Template of OLR filenames to input to the user-script +USER_SCRIPT_INPUT_TEMPLATE = {OBS_OLR_INPUT_DIR}/{OBS_OLR_INPUT_TEMPLATE} + +## Name of the file containing the listing of OLR input files +## The options are OBS_OLR_INPUT and FCST_OLR_INPUT +## *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE +USER_SCRIPT_INPUT_TEMPLATE_LABELS = OBS_OLR_INPUT + +# Command to run the user script with input configuration file +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py new file mode 120000 index 0000000000..ff871c910e --- /dev/null +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_OMI/OMI_driver.py @@ -0,0 +1 @@ +../UserScript_fcstGFS_obsERA_OMI/OMI_driver.py \ No newline at end of file diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf similarity index 92% rename from parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf rename to parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf index 9326a96e2b..7f435f4034 100644 --- a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram.conf +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram.conf @@ -50,7 +50,7 @@ FCST_RUN = False # Input and Output Directories for the OBS OLR Files and output text file containing the file list OBS_PDTIME_FMT = %Y%m%d-%H%M%S OBS_PDTIME_INPUT_TEMPLATE = {valid?fmt=%Y%m%d-%H%M%S} -OBS_PDTIME_OUTPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/ +OBS_PDTIME_OUTPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/ OBS_PDTIME_OUTPUT_TEMPLATE = time_list_lead{lead?fmt=%HHH}.txt @@ -59,7 +59,7 @@ OBS_PDTIME_OUTPUT_TEMPLATE = time_list_lead{lead?fmt=%HHH}.txt # Find the files for each time USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_FOR_EACH -USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/save_input_files_txt.py {OBS_PDTIME_INPUT_TEMPLATE} {OBS_PDTIME_OUTPUT_DIR}/{OBS_PDTIME_OUTPUT_TEMPLATE} +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/save_input_files_txt.py {OBS_PDTIME_INPUT_TEMPLATE} {OBS_PDTIME_OUTPUT_DIR}/{OBS_PDTIME_OUTPUT_TEMPLATE} # Configurations for the Phase Diagram Plotting Script @@ -86,7 +86,7 @@ OBS_PHASE_DIAGRAM_INPUT_TIMELIST_TEXTFILE = {OBS_PDTIME_OUTPUT_DIR}/{OBS_PDTIME_ OBS_PHASE_DIAGRAM_INPUT_TIME_FMT = {OBS_PDTIME_FMT} # Plot Output Directory -PHASE_DIAGRAM_PLOT_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/plots +PHASE_DIAGRAM_PLOT_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/plots # Plot Ouptut Name OBS_PHASE_PLOT_OUTPUT_NAME = RMM_phase_diagram @@ -99,4 +99,4 @@ OBS_PHASE_PLOT_OUTPUT_NAME = RMM_phase_diagram USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD # Command to run the user script with input configuration file -USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/PhaseDiagram_driver.py +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/PhaseDiagram_driver.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/PhaseDiagram_driver.py b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/PhaseDiagram_driver.py similarity index 100% rename from parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/PhaseDiagram_driver.py rename to parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/PhaseDiagram_driver.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/save_input_files_txt.py b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/save_input_files_txt.py similarity index 100% rename from parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_PhaseDiagram/save_input_files_txt.py rename to parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_PhaseDiagram/save_input_files_txt.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf new file mode 100644 index 0000000000..495a124b2f --- /dev/null +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM.conf @@ -0,0 +1,436 @@ +# RMM UserScript wrapper +[config] +# All steps, including creating daily means and mean daily annual cycle +#PROCESS_LIST = PcpCombine(mean_daily_annual_cycle_obs_wind), PcpCombine(mean_daily_annual_cycle_obs_olr), PcpCombine(daily_mean_obs_wind), PcpCombine(daily_mean_obs_olr), UserScript(create_mda_filelist), UserScript(harmonic_anomalies_olr), UserScript(harmonic_anomalies_u850), UserScript(harmonic_anomalies_u200), RegridDataPlane(regrid_obs_olr), RegridDataPlane(regrid_obs_u850), RegridDataPlane(regrid_obs_u200), UserScript(script_rmm) +# Computing anomalies, regridding, and RMM Analysis script +PROCESS_LIST = UserScript(create_mda_filelist), UserScript(harmonic_anomalies_olr), UserScript(harmonic_anomalies_u850), UserScript(harmonic_anomalies_u200), RegridDataPlane(regrid_obs_olr), RegridDataPlane(regrid_obs_u850), RegridDataPlane(regrid_obs_u200), UserScript(script_rmm) + +# time looping - options are INIT, VALID, RETRO, and REALTIME +# If set to INIT or RETRO: +# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set +# If set to VALID or REALTIME: +# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set +LOOP_BY = VALID + +# Format of VALID_BEG and VALID_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d%H + +# Start time for METplus run +VALID_BEG = 2000010100 + +# End time for METplus run +VALID_END = 2002123000 + +# Increment between METplus runs in seconds. Must be >= 60 +VALID_INCREMENT = 86400 + +# List of forecast leads to process for each run time (init or valid) +# In hours if units are not specified +# If unset, defaults to 0 (don't loop through forecast leads) +LEAD_SEQ = 0 + +# Order of loops to process data - Options are times, processes +# Not relevant if only one item is in the PROCESS_LIST +# times = run all wrappers in the PROCESS_LIST for a single run time, then +# increment the run time and run all wrappers again until all times have +# been evaluated. +# processes = run the first wrapper in the PROCESS_LIST for all times +# specified, then repeat for the next item in the PROCESS_LIST until all +# wrappers have been run +LOOP_ORDER = processes + +# location of configuration files used by MET applications +CONFIG_DIR={PARM_BASE}/use_cases/model_applications/s2s + +# Run the obs for these cases +OBS_RUN = True +FCST_RUN = False + +# Mask to use for regridding +REGRID_DATA_PLANE_VERIF_GRID = latlon 144 13 -15 0 2.5 2.5 + +# Method to run regrid_data_plane, not setting this will default to NEAREST +REGRID_DATA_PLANE_METHOD = NEAREST + +# Regridding width used in regrid_data_plane, not setting this will default to 1 +REGRID_DATA_PLANE_WIDTH = 1 + + +# Configurations for creating U200 and U850 mean daily annual cycle obs +# Mean daily annual cycle anomalies are computed for 1979 - 2001 +[mean_daily_annual_cycle_obs_wind] +LOOP_BY = VALID + +# Format of VALID_BEG and VALID_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d%H + +# Start time for METplus run +# Set to one year, since we want a mean daily across all years +# Using 2012 because leap day will be included +VALID_BEG = 2012010100 + +# End time for METplus run +VALID_END = 2012123100 + +# Increment between METplus runs in seconds. Must be >= 60 +VALID_INCREMENT = 86400 + +# run pcp_combine on obs data +OBS_PCP_COMBINE_RUN = {OBS_RUN} + +# method to run pcp_combine on forecast data +# Options are ADD, SUM, SUBTRACT, DERIVE, and USER_DEFINED +OBS_PCP_COMBINE_METHOD = USER_DEFINED + +OBS_PCP_COMBINE_COMMAND = -derive mean {OBS_PCP_COMBINE_INPUT_DIR}/{OBS_PCP_COMBINE_INPUT_TEMPLATE} -field 'name="U_P850_mean"; level="(*,*)"; set_attr_valid = "{valid?fmt=%Y%m%d_%H%M%S}";' -field 'name="U_P200_mean"; level="(*,*)"; set_attr_valid = "{valid?fmt=%Y%m%d_%H%M%S}";' -name U_P850_mean,U_P200_mean + +OBS_PCP_COMBINE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean +OBS_PCP_COMBINE_INPUT_TEMPLATE = ERA_wind_daily_mean_*{valid?fmt=%m%d}.nc + +OBS_PCP_COMBINE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/mean_daily_annual_cycle +OBS_PCP_COMBINE_OUTPUT_TEMPLATE = ERA_wind_daily_annual_{valid?fmt=%m%d}.nc + + +# Configurations for creating OLR mean daily annual cycle obs +# Mean daily annual cycle anomalies are computed for 1979 - 2001 +[mean_daily_annual_cycle_obs_olr] +LOOP_BY = VALID + +# Format of VALID_BEG and VALID_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d%H + +# Start time for METplus run +# Set to one year, since we want a mean daily across all years +# Using 2012 because leap day will be included +VALID_BEG = 2012010100 + +# End time for METplus run +VALID_END = 2012123100 + +# Increment between METplus runs in seconds. Must be >= 60 +VALID_INCREMENT = 86400 + +# run pcp_combine on obs data +OBS_PCP_COMBINE_RUN = {OBS_RUN} + +# method to run pcp_combine on forecast data +# Options are ADD, SUM, SUBTRACT, DERIVE, and USER_DEFINED +OBS_PCP_COMBINE_METHOD = USER_DEFINED + +OBS_PCP_COMBINE_COMMAND = -derive mean {OBS_PCP_COMBINE_INPUT_DIR}/{OBS_PCP_COMBINE_INPUT_TEMPLATE} -field 'name="olr"; level="(*,*)";' + +OBS_PCP_COMBINE_INPUT_DIR = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean +OBS_PCP_COMBINE_INPUT_TEMPLATE = ERA_OLR_daily_mean_*{valid?fmt=%m%d}.nc + +OBS_PCP_COMBINE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/mean_daily_annual_cycle +OBS_PCP_COMBINE_OUTPUT_TEMPLATE = ERA_OLR_daily_annual_{valid?fmt=%m%d}.nc + + +# Configurations for creating U200 and U850 daily mean obs +[daily_mean_obs_wind] +LOOP_BY = VALID + +# Format of VALID_BEG and VALID_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d%H + +# Start time for METplus run +VALID_BEG = 1979010100 + +# End time for METplus run +VALID_END = 2002123100 + +# Increment between METplus runs in seconds. Must be >= 60 +VALID_INCREMENT = 86400 + +# run pcp_combine on obs data +OBS_PCP_COMBINE_RUN = {OBS_RUN} + +# method to run pcp_combine on forecast data +# Options are ADD, SUM, SUBTRACT, DERIVE, and USER_DEFINED +OBS_PCP_COMBINE_METHOD = USER_DEFINED + +OBS_PCP_COMBINE_COMMAND = -derive mean {OBS_PCP_COMBINE_INPUT_DIR}/{OBS_PCP_COMBINE_INPUT_TEMPLATE} -field 'name="U"; level="P850"; set_attr_valid = "{valid?fmt=%Y%m%d_%H%M%S}";' -field 'name="U"; level="P200"; set_attr_valid = "{valid?fmt=%Y%m%d_%H%M%S}";' + +OBS_PCP_COMBINE_INPUT_DIR = /gpfs/fs1/collections/rda/data/ds627.0/ei.oper.an.pl +OBS_PCP_COMBINE_INPUT_TEMPLATE = {valid?fmt=%Y%m}/ei.oper.an.pl.regn128uv.{valid?fmt=%Y%m%d}* + +OBS_PCP_COMBINE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean +OBS_PCP_COMBINE_OUTPUT_TEMPLATE = ERA_wind_daily_mean_{valid?fmt=%Y%m%d}.nc + + +# Configurations for creating mean daily annual cycle obs OLR +[daily_mean_obs_olr] +LOOP_BY = VALID + +# Format of VALID_BEG and VALID_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d%H + +# Start time for METplus run +VALID_BEG = 1979010100 + +# End time for METplus run +VALID_END = 2002123100 + +# Increment between METplus runs in seconds. Must be >= 60 +VALID_INCREMENT = 86400 + +# run pcp_combine on obs data +OBS_PCP_COMBINE_RUN = {OBS_RUN} + +# method to run pcp_combine on forecast data +# Options are ADD, SUM, SUBTRACT, DERIVE, and USER_DEFINED +OBS_PCP_COMBINE_METHOD = USER_DEFINED + +OBS_PCP_COMBINE_COMMAND = -add {OBS_PCP_COMBINE_INPUT_DIR}/{OBS_PCP_COMBINE_INPUT_TEMPLATE} -field 'name="olr"; level="({valid?fmt=%Y%m%d_%H%M%S},*,*)"; file_type=NETCDF_NCCF;' + +OBS_PCP_COMBINE_INPUT_DIR = /glade/u/home/kalb/MJO +OBS_PCP_COMBINE_INPUT_TEMPLATE = olr.1x.7920.nc + +OBS_PCP_COMBINE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean +OBS_PCP_COMBINE_OUTPUT_TEMPLATE = ERA_OLR_daily_mean_{valid?fmt=%Y%m%d}.nc + + +# Creating a file list of the mean daily annual cycle files +# This is run separately since it has different start/end times +[create_mda_filelist] +# Find the files for each lead time +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD + +# Valid Begin and End Times for the CBL File Climatology +VALID_BEG = 2012010100 +VALID_END = 2012123100 +VALID_INCREMENT = 86400 +LEAD_SEQ = 0 + +# Template of filenames to input to the user-script +USER_SCRIPT_INPUT_TEMPLATE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/mean_daily_annual_cycle/ERA_OLR_daily_annual_{valid?fmt=%m%d}.nc,{INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/mean_daily_annual_cycle/ERA_wind_daily_annual_{valid?fmt=%m%d}.nc + +# Name of the file containing the listing of input files +USER_SCRIPT_INPUT_TEMPLATE_LABELS = input_mean_daily_annual_infiles_olr,input_mean_daily_annual_infiles_wind + +# Placeholder command just to build the file list +# This just states that it's building the file list +USER_SCRIPT_COMMAND = echo Populated file list for Mean daily annual cycle Input + + +# Configurations to create anomalies for OLR +[harmonic_anomalies_olr] +# list of strings to loop over for each run time. +# Run the user script once per lead +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD + +# Template of filenames to input to the user-script +USER_SCRIPT_INPUT_TEMPLATE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean/ERA_OLR_daily_mean_{valid?fmt=%Y%m%d}.nc + +# Name of the file containing the listing of input files +# The options are OBS_OLR_INPUT, OBS_U850_INPUT, OBS_U200_INPUT, FCST_OLR_INPUT, FCST_U850_INPUT, and FCST_U200_INPUT +# *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE +USER_SCRIPT_INPUT_TEMPLATE_LABELS = input_daily_mean_infiles + +# Command to run the user script with input configuration file +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py 'METPLUS_FILELIST_INPUT_MEAN_DAILY_ANNUAL_INFILES_OLR' 'olr' 'olr_NA_mean' '{OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Anomaly' 'ERA_OLR_anom' + + +# Configurations to create anomalies for U850 +[harmonic_anomalies_u850] +# list of strings to loop over for each run time. +# Run the user script once per lead +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD + +# Template of filenames to input to the user-script +USER_SCRIPT_INPUT_TEMPLATE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean/ERA_wind_daily_mean_{valid?fmt=%Y%m%d}.nc + +# Name of the file containing the listing of input files +# The options are OBS_OLR_INPUT, OBS_U850_INPUT, OBS_U200_INPUT, FCST_OLR_INPUT, FCST_U850_INPUT, and FCST_U200_INPUT +# *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE +USER_SCRIPT_INPUT_TEMPLATE_LABELS = input_daily_mean_infiles + +# Command to run the user script with input configuration file +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py 'METPLUS_FILELIST_INPUT_MEAN_DAILY_ANNUAL_INFILES_WIND' 'U_P850_mean' 'U_P850_mean' '{OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Anomaly' 'ERA_U850_anom' + + +# Configurations to create anomalies for U200 +[harmonic_anomalies_u200] +# list of strings to loop over for each run time. +# Run the user script once per lead +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD + +# Template of filenames to input to the user-script +USER_SCRIPT_INPUT_TEMPLATE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/ERA/daily_mean/ERA_wind_daily_mean_{valid?fmt=%Y%m%d}.nc + +# Name of the file containing the listing of input files +# The options are OBS_OLR_INPUT, OBS_U850_INPUT, OBS_U200_INPUT, FCST_OLR_INPUT, FCST_U850_INPUT, and FCST_U200_INPUT +# *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE +USER_SCRIPT_INPUT_TEMPLATE_LABELS = input_daily_mean_infiles + +# Command to run the user script with input configuration file +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py 'METPLUS_FILELIST_INPUT_MEAN_DAILY_ANNUAL_INFILES_WIND' 'U_P200_mean' 'U_P200_mean' '{OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Anomaly' 'ERA_U200_anom' + + +# Configurations for regrid_data_plane: Regrid OLR to -15 to 15 latitude +[regrid_obs_olr] +# Run regrid_data_plane on forecast data +OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} + +# If true, process each field individually and write a file for each +# If false, run once per run time passing in all fields specified +REGRID_DATA_PLANE_ONCE_PER_FIELD = False + +# Name of input field to process +OBS_REGRID_DATA_PLANE_VAR1_NAME = olr_anom + +# Level of input field to process +OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "(*,*)" + +# Name of output field to create +OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = OLR_anom + +# input and output data directories for each application in PROCESS_LIST +OBS_REGRID_DATA_PLANE_INPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Anomaly +OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Regrid + +# format of filenames +# Input ERA Interim +OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = ERA_OLR_anom_{lead?fmt=%H%M%S}L_{valid?fmt=%Y%m%d}_{valid?fmt=%H%M%S}V.nc +OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = ERA_OLR_{valid?fmt=%Y%m%d}.nc + + +# Configurations for regrid_data_plane: Regrid u850 to -15 to 15 latitude +[regrid_obs_u850] +# Run regrid_data_plane on forecast data +OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} + +# If true, process each field individually and write a file for each +# If false, run once per run time passing in all fields specified +REGRID_DATA_PLANE_ONCE_PER_FIELD = False + +# Name of input field to process +OBS_REGRID_DATA_PLANE_VAR1_NAME = U_P850_mean_anom + +# Level of input field to process +OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "(*,*)" + +# Name of output field to create +OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = U_P850_anom + +# input and output data directories for each application in PROCESS_LIST +OBS_REGRID_DATA_PLANE_INPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Anomaly +OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Regrid + +# format of filenames +# Input ERA Interim +OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = ERA_U850_anom_{lead?fmt=%H%M%S}L_{valid?fmt=%Y%m%d}_{valid?fmt=%H%M%S}V.nc +OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = ERA_U850_{valid?fmt=%Y%m%d}.nc + + +# Configurations for regrid_data_plane: Regrid u200 to -15 to 15 latitude +[regrid_obs_u200] +# Run regrid_data_plane on forecast data +OBS_REGRID_DATA_PLANE_RUN = {OBS_RUN} + +# If true, process each field individually and write a file for each +# If false, run once per run time passing in all fields specified +REGRID_DATA_PLANE_ONCE_PER_FIELD = False + +# Name of input field to process +OBS_REGRID_DATA_PLANE_VAR1_NAME = U_P200_mean_anom + +# Level of input field to process +OBS_REGRID_DATA_PLANE_VAR1_LEVELS = "(*,*)" + +# Name of output field to create +OBS_REGRID_DATA_PLANE_VAR1_OUTPUT_FIELD_NAME = U_P200_anom + +# input and output data directories for each application in PROCESS_LIST +OBS_REGRID_DATA_PLANE_INPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Anomaly +OBS_REGRID_DATA_PLANE_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Regrid + +# format of filenames +# Input ERA Interim +OBS_REGRID_DATA_PLANE_INPUT_TEMPLATE = ERA_U200_anom_{lead?fmt=%H%M%S}L_{valid?fmt=%Y%m%d}_{valid?fmt=%H%M%S}V.nc +OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE = ERA_U200_{valid?fmt=%Y%m%d}.nc + + +# Configurations for the RMM analysis script +[user_env_vars] +# Whether to Run the model or obs +RUN_OBS = {OBS_RUN} +RUN_FCST = {FCST_RUN} + +# Make OUTPUT_BASE Available to the script +SCRIPT_OUTPUT_BASE = {OUTPUT_BASE} + +# Number of obs per day +OBS_PER_DAY = 1 + +# Variable names for OLR, U850, U200 +OBS_OLR_VAR_NAME = OLR_anom +OBS_U850_VAR_NAME = U_P850_anom +OBS_U200_VAR_NAME = U_P200_anom + +# EOF Filename +OLR_EOF_INPUT_TEXTFILE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/EOF/rmm_olr_eofs.txt +U850_EOF_INPUT_TEXTFILE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/EOF/rmm_u850_eofs.txt +U200_EOF_INPUT_TEXTFILE = {INPUT_BASE}/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/EOF/rmm_u200_eofs.txt + +# Normalization factors for RMM +RMM_OLR_NORM = 15.11623 +RMM_U850_NORM = 1.81355 +RMM_U200_NORM = 4.80978 +PC1_NORM = 8.618352504159244 +PC2_NORM = 8.40736449709697 + +# Output Directory for the plots +# If not set, it this will default to {OUTPUT_BASE}/plots +RMM_PLOT_OUTPUT_DIR = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/plots + +# EOF plot information +EOF_PLOT_OUTPUT_NAME = RMM_EOFs +EOF_PLOT_OUTPUT_FORMAT = png + +# Phase Plot start date, end date, output name, and format +PHASE_PLOT_TIME_BEG = 2002010100 +PHASE_PLOT_TIME_END = 2002123000 +PHASE_PLOT_TIME_FMT = {VALID_TIME_FMT} +OBS_PHASE_PLOT_OUTPUT_NAME = obs_RMM_comp_phase +OBS_PHASE_PLOT_OUTPUT_FORMAT = png + +# Time Series Plot start date, end date, output name, and format +TIMESERIES_PLOT_TIME_BEG = 2002010100 +TIMESERIES_PLOT_TIME_END = 2002123000 +TIMESERIES_PLOT_TIME_FMT = {VALID_TIME_FMT} +OBS_TIMESERIES_PLOT_OUTPUT_NAME = obs_RMM_time_series +OBS_TIMESERIES_PLOT_OUTPUT_FORMAT = png + + +# Configurations for UserScript: Run the RMM Analysis driver +[script_rmm] +# list of strings to loop over for each run time. +# Run the user script once per lead +USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE_PER_LEAD + +# Template of filenames to input to the user-script +USER_SCRIPT_INPUT_TEMPLATE = {OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Regrid/ERA_OLR_{valid?fmt=%Y%m%d}.nc,{OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Regrid/ERA_U850_{valid?fmt=%Y%m%d}.nc,{OUTPUT_BASE}/s2s/UserScript_obsERA_obsOnly_RMM/ERA/Regrid/ERA_U200_{valid?fmt=%Y%m%d}.nc + +# Name of the file containing the listing of input files +# The options are OBS_OLR_INPUT, OBS_U850_INPUT, OBS_U200_INPUT, FCST_OLR_INPUT, FCST_U850_INPUT, and FCST_U200_INPUT +# *** Make sure the order is the same as the order of templates listed in USER_SCRIPT_INPUT_TEMPLATE +USER_SCRIPT_INPUT_TEMPLATE_LABELS = OBS_OLR_INPUT,OBS_U850_INPUT, OBS_U200_INPUT + +# Command to run the user script with input configuration file +USER_SCRIPT_COMMAND = {METPLUS_BASE}/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/RMM_driver.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/RMM_driver.py b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/RMM_driver.py similarity index 88% rename from parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/RMM_driver.py rename to parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/RMM_driver.py index d33906c9d8..3e3f5b741f 100755 --- a/parm/use_cases/model_applications/s2s/UserScript_fcstGFS_obsERA_RMM/RMM_driver.py +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/RMM_driver.py @@ -48,11 +48,15 @@ def read_rmm_eofs(olrfile, u850file, u200file): def run_rmm_steps(inlabel, spd, EOF1, EOF2, oplot_dir): - # Get OLR, U850, U200 file listings + # Get OLR, U850, U200 file listings and variable names olr_filetxt = os.environ['METPLUS_FILELIST_'+inlabel+'_OLR_INPUT'] u850_filetxt = os.environ['METPLUS_FILELIST_'+inlabel+'_U850_INPUT'] u200_filetxt = os.environ['METPLUS_FILELIST_'+inlabel+'_U200_INPUT'] + olr_var = os.environ[inlabel+'_OLR_VAR_NAME'] + u850_var = os.environ[inlabel+'_U850_VAR_NAME'] + u200_var = os.environ[inlabel+'_U200_VAR_NAME'] + # Read the listing of OLR, U850, U200 files with open(olr_filetxt) as ol: olr_input_files = ol.read().splitlines() @@ -67,6 +71,18 @@ def run_rmm_steps(inlabel, spd, EOF1, EOF2, oplot_dir): if (u200_input_files[0] == 'file_list'): u200_input_files = u200_input_files[1:] + # Check the input data to make sure it's not all missing + olr_allmissing = all(elem == 'missing' for elem in olr_input_files) + if olr_allmissing: + raise IOError ('No input OLR files were found, check file paths') + u850_allmissing = all(elem == 'missing' for elem in u850_input_files) + if u850_allmissing: + raise IOError('No input U850 files were found, check file paths') + u200_allmissing = all(elem == 'missing' for elem in u200_input_files) + if u200_allmissing: + raise IOError('No input U200 files were found, check file paths') + + # Read OLR, U850, U200 data from file netcdf_reader_olr = read_netcdf.ReadNetCDF() ds_olr = netcdf_reader_olr.read_into_xarray(olr_input_files) @@ -81,7 +97,7 @@ def run_rmm_steps(inlabel, spd, EOF1, EOF2, oplot_dir): time = [] for din in range(len(ds_olr)): colr = ds_olr[din] - ctime = datetime.datetime.strptime(colr['olr'].valid_time,'%Y%m%d_%H%M%S') + ctime = datetime.datetime.strptime(colr[olr_var].valid_time,'%Y%m%d_%H%M%S') time.append(ctime.strftime('%Y-%m-%d')) colr = colr.assign_coords(time=ctime) ds_olr[din] = colr.expand_dims("time") @@ -97,17 +113,17 @@ def run_rmm_steps(inlabel, spd, EOF1, EOF2, oplot_dir): time = np.array(time,dtype='datetime64[D]') everything_olr = xr.concat(ds_olr,"time") - olr = everything_olr['olr'] + olr = everything_olr[olr_var] olr = olr.mean('lat') print(olr.min(), olr.max()) everything_u850 = xr.concat(ds_u850,"time") - u850 = everything_u850['uwnd850'] + u850 = everything_u850[u850_var] u850 = u850.mean('lat') print(u850.min(), u850.max()) everything_u200 = xr.concat(ds_u200,"time") - u200 = everything_u200['uwnd200'] + u200 = everything_u200[u200_var] u200 = u200.mean('lat') print(u200.min(), u200.max()) @@ -186,7 +202,7 @@ def main(): # Determine if doing forecast or obs run_obs_rmm = os.environ.get('RUN_OBS', 'False').lower() - run_fcst_rmm = os.environ.get('FCST_RUN_FCST', 'False').lower() + run_fcst_rmm = os.environ.get('RUN_FCST', 'False').lower() if (run_obs_rmm == 'true'): run_rmm_steps('OBS', spd, EOF1, EOF2, oplot_dir) diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py new file mode 100755 index 0000000000..6c6fe9dad7 --- /dev/null +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_RMM/compute_harmonic_anomalies.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +import numpy as np +import xarray as xr +import glob +import os +import sys +import datetime +import METreadnc.util.read_netcdf as read_netcdf + +input_mean_daily_annual_infiles_list = os.environ[sys.argv[1]] +dm_var = sys.argv[2] +mda_var = sys.argv[3] +anom_output_dir = sys.argv[4] +anom_output_base = sys.argv[5] +input_daily_mean_infiles_list = os.environ['METPLUS_FILELIST_INPUT_DAILY_MEAN_INFILES'] + +# Environment variables for script +nobs = int(os.environ.get('OBS_PER_DAY',1)) +out_var = dm_var+'_anom' + +# Read the listing of files +with open(input_daily_mean_infiles_list) as idm: + input_daily_mean_infiles = idm.read().splitlines() +if (input_daily_mean_infiles[0] == 'file_list'): + input_daily_mean_infiles = input_daily_mean_infiles[1:] + +with open(input_mean_daily_annual_infiles_list) as imda: + input_mean_daily_annual_infiles = imda.read().splitlines() +if (input_mean_daily_annual_infiles[0] == 'file_list'): + input_mean_daily_annual_infiles = input_mean_daily_annual_infiles[1:] + + +# Read in the data +netcdf_reader = read_netcdf.ReadNetCDF() +dm_orig = netcdf_reader.read_into_xarray(input_daily_mean_infiles) +# Add some needed attributes +dm_list = [] +time_dm = [] +yr_dm = [] +doy_dm = [] +for din in dm_orig: + ctime = datetime.datetime.strptime(din[dm_var].valid_time,'%Y%m%d_%H%M%S') + time_dm.append(ctime.strftime('%Y-%m-%d')) + yr_dm.append(int(ctime.strftime('%Y'))) + doy_dm.append(int(ctime.strftime('%j'))) + din = din.assign_coords(time=ctime) + din = din.expand_dims("time") + dm_list.append(din) +time_dm = np.array(time_dm,dtype='datetime64[D]') +yr_dm = np.array(yr_dm) +doy_dm = np.array(doy_dm) +everything = xr.concat(dm_list,"time") +dm_data = np.array(everything[dm_var]) + +netcdf_reader2 = read_netcdf.ReadNetCDF() +mda_orig = netcdf_reader2.read_into_xarray(input_mean_daily_annual_infiles) +# Add some needed attributes +mda_list = [] +time_mda = [] +for din in mda_orig: + ctime = datetime.datetime.strptime(din[mda_var].valid_time,'%Y%m%d_%H%M%S') + time_mda.append(ctime.strftime('%Y-%m-%d')) + din = din.assign_coords(time=ctime) + din = din.expand_dims("time") + mda_list.append(din) +time_mda = np.array(time_mda,dtype='datetime64[D]') +everything2 = xr.concat(mda_list,"time") +mda_data = np.array(everything2[mda_var]) + +# Harmonic Analysis, first step is Forward Fast Fourier Transform +clmfft = np.fft.rfft(mda_data,axis=0) + +smthfft = np.zeros(clmfft.shape,dtype=complex) +for f in np.arange(0,3): + smthfft[f,:,:] = clmfft[f,:,:] + +clmout = np.fft.irfft(smthfft,axis=0) + +# Subtract the clmout from the data to create anomalies, each year at a time +yrstrt = yr_dm[0] +yrend = yr_dm[-1] +anom = np.zeros(dm_data.shape) + +for y in np.arange(yrstrt,yrend+1,1): + curyr = np.where(yr_dm == y) + dd = doy_dm[curyr] - 1 + ndd = len(curyr[0]) + clmshp = [np.arange(dd[0]*nobs,dd[0]*nobs+ndd,1)] + anom[curyr,:,:] = dm_data[curyr,:,:] - clmout[clmshp,:,:] + +# Assign to an xarray and write output +if not os.path.exists(anom_output_dir): + os.makedirs(anom_output_dir) +for o in np.arange(0,len(dm_orig)): + dm_orig_cur = dm_orig[o] + dout = xr.Dataset({out_var: (("lat", "lon"),anom[o,:,:])}, + coords={"lat": dm_orig_cur.coords['lat'], "lon": dm_orig_cur.coords['lon']}, + attrs=dm_orig_cur.attrs) + dout[out_var].attrs = dm_orig_cur[dm_var].attrs + dout[out_var].attrs['long_name'] = dm_orig_cur[dm_var].attrs['long_name']+' Anomalies' + dout[out_var].attrs['name'] = out_var + + # write to a file + cvtime = datetime.datetime.strptime(dm_orig_cur[dm_var].valid_time,'%Y%m%d_%H%M%S') + citime = datetime.datetime.strptime(dm_orig_cur[dm_var].init_time,'%Y%m%d_%H%M%S') + cltime = (cvtime - citime) + leadmin,leadsec = divmod(cltime.total_seconds(), 60) + leadhr,leadmin = divmod(leadmin,60) + lead_str = str(int(leadhr)).zfill(2)+str(int(leadmin)).zfill(2)+str(int(leadsec)).zfill(2) + dout.to_netcdf(os.path.join(anom_output_dir,anom_output_base+'_'+lead_str+'L_'+cvtime.strftime('%Y%m%d')+'_'+cvtime.strftime('%H%M%S')+'V.nc')) From 4fbb6892f2a92cc495c1fbc96b74f00f36c4ce82 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 16 Nov 2021 14:39:09 -0700 Subject: [PATCH 13/43] Feature 1266 gen ens prod missing ensembles (#1275) --- .../met_tool_wrapper/GenEnsProd/GenEnsProd.py | 31 +++++- .../test_ensemble_stat_wrapper.py | 4 +- metplus/util/met_util.py | 6 +- metplus/wrappers/command_builder.py | 104 +++++++++++++++++- metplus/wrappers/ensemble_stat_wrapper.py | 28 ++--- metplus/wrappers/gen_ens_prod_wrapper.py | 77 +++++-------- parm/met_config/GenEnsProdConfig_wrapped | 4 +- .../GenEnsProd/GenEnsProd.conf | 16 ++- 8 files changed, 193 insertions(+), 77 deletions(-) diff --git a/docs/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.py b/docs/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.py index 1408437518..8109723a90 100644 --- a/docs/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.py +++ b/docs/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.py @@ -9,7 +9,21 @@ # Scientific Objective # -------------------- # -# Generate ensemble products. +# Generate ensemble products. This use case demonstrates how to configure +# the gen_ens_prod tool if you expect that there will occasionally be missing +# ensembles. 7 ensemble paths are specified but only 6 of them exist in the +# sample input data set. The wrapper will mark ensembles that are not found +# with the MISSING keyword in the file-list file that is read by the tool. +# Also, one of the ensembles is listed as the control member. The gen_ens_prod +# application will error and exit if the control member is included in the +# ensemble list, but the GenEnsProd wrapper will automatically remove the +# control member from the ensemble list. This makes it easier to configure +# the tool to change the control member without having to change the ensemble +# list. The number of expected members (defined with GEN_ENS_PROD_N_MEMBERS) +# is 6 (7 members - 1 control member). The actual number of ensemble members +# that will be found in this example is 5 (arw-tom-gep4 is not included). +# The ens.ens_thresh value (defined by GEN_ENS_PROD_ENS_THRESH) is set to 0.8. +# There are ~0.833 (5/6) valid ensemble members so the application will run. ############################################################################## # Datasets @@ -95,6 +109,21 @@ # # * gen_ens_prod_20100101_120000V_ens.nc # +# A file-list file will also be generated in stage/file_lists called: +# +# * 20091231120000_24_gen_ens_prod.txt +# +# It should contain a list of 6 files in {INPUT_BASE} with 1 file marked as +# missing because it was not found:: +# +# file_list +# {INPUT_BASE}/met_test/data/sample_fcst/2009123112/arw-sch-gep2/d01_2009123112_02400.grib +# {INPUT_BASE}/met_test/data/sample_fcst/2009123112/arw-tom-gep3/d01_2009123112_02400.grib +# MISSING/{INPUT_BASE}/met_test/data/sample_fcst/2009123112/arw-tom-gep4/d01_2009123112_02400.grib +# {INPUT_BASE}/met_test/data/sample_fcst/2009123112/arw-fer-gep5/d01_2009123112_02400.grib +# {INPUT_BASE}/met_test/data/sample_fcst/2009123112/arw-sch-gep6/d01_2009123112_02400.grib +# {INPUT_BASE}/met_test/data/sample_fcst/2009123112/arw-tom-gep7/d01_2009123112_02400.grib +# ############################################################################## # Keywords diff --git a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py index 750fe994af..be6ee1acb4 100644 --- a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py +++ b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py @@ -568,10 +568,10 @@ def test_ensemble_stat_single_field(metplus_config, config_overrides, config_file = wrapper.c_dict.get('CONFIG_FILE') out_dir = wrapper.c_dict.get('OUTPUT_DIR') expected_cmds = [(f"{app_path} {verbosity} " - f"{file_list_dir}/20050807000000_12_ensemble.txt " + f"{file_list_dir}/20050807000000_12_ensemble_stat.txt " f"{config_file} -outdir {out_dir}/2005080712"), (f"{app_path} {verbosity} " - f"{file_list_dir}/20050807120000_12_ensemble.txt " + f"{file_list_dir}/20050807120000_12_ensemble_stat.txt " f"{config_file} -outdir {out_dir}/2005080800"), ] diff --git a/metplus/util/met_util.py b/metplus/util/met_util.py index 414b9795a4..105740e420 100644 --- a/metplus/util/met_util.py +++ b/metplus/util/met_util.py @@ -2282,7 +2282,11 @@ def format_var_items(field_configs, time_info=None): return var_items def find_var_name_indices(config, data_types, met_tool=None): - data_type_regex = f"{'|'.join(data_types)}|BOTH" + data_type_regex = f"{'|'.join(data_types)}" + + # if data_types includes FCST or OBS, also search for BOTH + if any([item for item in ['FCST', 'OBS'] if item in data_types]): + data_type_regex += '|BOTH' regex_string = f"({data_type_regex})" diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 4384f45307..22b1052721 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -22,6 +22,7 @@ from ..util import do_string_sub, ti_calculate, get_seconds_from_string from ..util import config_metplus from ..util import METConfigInfo as met_config +from ..util import MISSING_DATA_VALUE # pylint:disable=pointless-string-statement '''!@namespace CommandBuilder @@ -659,6 +660,8 @@ def find_exact_file(self, level, data_type, time_info, mandatory=True, # then add it back after the string sub call saved_level = time_info.pop('level', None) + input_must_exist = self.c_dict.get('INPUT_MUST_EXIST', True) + for template in template_list: # perform string substitution filename = do_string_sub(template, @@ -671,9 +674,9 @@ def find_exact_file(self, level, data_type, time_info, mandatory=True, if os.path.sep not in full_path: self.logger.debug(f"{full_path} is not a file path. " "Returning that string.") - if return_list: - full_path = [full_path] - return full_path + check_file_list.append(full_path) + input_must_exist = False + continue self.logger.debug(f"Looking for {data_type}INPUT file {full_path}") @@ -719,7 +722,7 @@ def find_exact_file(self, level, data_type, time_info, mandatory=True, for file_path in check_file_list: # if file doesn't need to exist, skip check - if not self.c_dict.get('INPUT_MUST_EXIST', True): + if not input_must_exist: found_file_list.append(file_path) continue @@ -736,6 +739,9 @@ def find_exact_file(self, level, data_type, time_info, mandatory=True, f"using template {template}") if not mandatory or not self.c_dict.get('MANDATORY', True): self.logger.warning(msg) + if self.c_dict.get(f'{data_type}FILL_MISSING'): + found_file_list.append(f'MISSING{file_path}') + continue else: self.log_error(msg) @@ -843,6 +849,96 @@ def find_file_in_window(self, level, data_type, time_info, mandatory=True, return out + def find_input_files_ensemble(self, time_info): + """! Get a list of all input files and optional control file. + Warn and remove control file if found in ensemble list. Ensure that + if defined, the number of ensemble members (N_MEMBERS) corresponds to + the file list that was found. + + @param time_info dictionary containing timing information + @returns True on success + """ + # get list of ensemble files to process + input_files = self.find_model(time_info, return_list=True) + if not input_files: + self.log_error("Could not find any input files") + return False + + # get control file if requested + if self.c_dict.get('CTRL_INPUT_TEMPLATE'): + ctrl_file = self.find_data(time_info, data_type='CTRL') + + # return if requested control file was not found + if not ctrl_file: + return False + + self.args.append(f'-ctrl {ctrl_file}') + + # check if control file is found in ensemble list + if ctrl_file in input_files: + # warn and remove control file if found + self.logger.warning(f"Control file found in ensemble list: " + f"{ctrl_file}. Removing from list.") + input_files.remove(ctrl_file) + + # compare number of files found to expected number of members + if not self._check_expected_ensembles(input_files): + return False + + # write file that contains list of ensemble files + list_filename = (f"{time_info['init_fmt']}_" + f"{time_info['lead_hours']}_{self.app_name}.txt") + list_file = self.write_list_file(list_filename, input_files) + if not list_file: + self.log_error("Could not write filelist file") + return False + + self.infiles.append(list_file) + + return True + + def _check_expected_ensembles(self, input_files): + """! Helper function for find_input_files_ensemble(). + If number of expected ensemble members was defined in the config, + then ensure that the number of files found correspond to the expected + number. If more files were found, error and return False. If fewer + files were found, fill in input_files list with MISSING to allow valid + threshold check inside MET tool to work properly. + """ + num_expected = self.c_dict['N_MEMBERS'] + + # if expected members count is unset, skip check + if num_expected == MISSING_DATA_VALUE: + return True + + num_found = len(input_files) + + # error and return if more than expected number was found + if num_found > num_expected: + self.log_error( + "Found more files than expected! " + f"Found {num_found} expected {num_expected}. " + "Adjust wildcard expression in template or adjust " + "number of expected members (N_MEMBERS). " + f"Files found: {input_files}" + ) + return False + + # if fewer files found than expected, warn and add fake files + if num_found < num_expected: + self.logger.warning( + f"Found fewer files than expected. " + f"Found {num_found} expected {num_expected}" + ) + # add fake files to list for ens_thresh checking + diff = num_expected - num_found + self.logger.warning(f'Adding {diff} fake files to ' + 'ensure ens_thresh check is accurate') + for _ in range(0, diff, 1): + input_files.append('MISSING') + + return True + def write_list_file(self, filename, file_list, output_dir=None): """! Writes a file containing a list of filenames to the staging dir diff --git a/metplus/wrappers/ensemble_stat_wrapper.py b/metplus/wrappers/ensemble_stat_wrapper.py index 652170ffbe..2c532ec05f 100755 --- a/metplus/wrappers/ensemble_stat_wrapper.py +++ b/metplus/wrappers/ensemble_stat_wrapper.py @@ -153,11 +153,18 @@ def create_c_dict(self): elif c_dict['OBS_GRID_INPUT_DATATYPE'] in util.PYTHON_EMBEDDING_TYPES: c_dict['OBS_INPUT_DATATYPE'] = c_dict['OBS_GRID_INPUT_DATATYPE'] - c_dict['N_MEMBERS'] = \ - self.config.getint('config', 'ENSEMBLE_STAT_N_MEMBERS', -1) + c_dict['N_MEMBERS'] = ( + self.config.getint('config', 'ENSEMBLE_STAT_N_MEMBERS') + ) + + # allow multiple files in CommandBuilder.find_data logic + c_dict['ALLOW_MULTIPLE_FILES'] = True + + # not all input files are mandatory to be found + c_dict['MANDATORY'] = False - if c_dict['N_MEMBERS'] < 0: - self.log_error("Must set ENSEMBLE_STAT_N_MEMBERS to a integer > 0") + # fill inputs that are not found with fake path to note it is missing + c_dict['FCST_FILL_MISSING'] = True c_dict['OBS_POINT_INPUT_DIR'] = \ self.config.getdir('OBS_ENSEMBLE_STAT_POINT_INPUT_DIR', '') @@ -177,11 +184,9 @@ def create_c_dict(self): c_dict['FCST_INPUT_DIR'] = \ self.config.getdir('FCST_ENSEMBLE_STAT_INPUT_DIR', '') - # This is a raw string and will be interpreted to generate the - # ensemble member filenames. This may be a list of 1 or n members. - c_dict['FCST_INPUT_TEMPLATE'] = \ - util.getlist(self.config.getraw('filename_templates', - 'FCST_ENSEMBLE_STAT_INPUT_TEMPLATE')) + c_dict['FCST_INPUT_TEMPLATE'] = ( + self.config.getraw('config', 'FCST_ENSEMBLE_STAT_INPUT_TEMPLATE') + ) if not c_dict['FCST_INPUT_TEMPLATE']: self.log_error("Must set FCST_ENSEMBLE_STAT_INPUT_TEMPLATE") @@ -364,12 +369,9 @@ def run_at_time_all_fields(self, time_info): @param time_info dictionary containing timing information """ # get ensemble model files - fcst_file_list = self.find_model_members(time_info) - if not fcst_file_list: + if not self.find_input_files_ensemble(time_info): return - self.infiles.append(fcst_file_list) - # parse var list for ENS fields ensemble_var_list = util.sub_var_list(self.c_dict['ENS_VAR_LIST_TEMP'], time_info) diff --git a/metplus/wrappers/gen_ens_prod_wrapper.py b/metplus/wrappers/gen_ens_prod_wrapper.py index 00c7c58e73..3beaebc66e 100755 --- a/metplus/wrappers/gen_ens_prod_wrapper.py +++ b/metplus/wrappers/gen_ens_prod_wrapper.py @@ -7,6 +7,7 @@ from ..util import do_string_sub, ti_calculate, get_lead_sequence from ..util import skip_time, parse_var_list, sub_var_list + from . import LoopTimesWrapper class GenEnsProdWrapper(LoopTimesWrapper): @@ -21,8 +22,8 @@ class GenEnsProdWrapper(LoopTimesWrapper): 'METPLUS_CAT_THRESH', 'METPLUS_NC_VAR_STR', 'METPLUS_ENS_FILE_TYPE', - 'METPLUS_ENS_ENS_THRESH', - 'METPLUS_ENS_VLD_THRESH', + 'METPLUS_ENS_THRESH', + 'METPLUS_VLD_THRESH', 'METPLUS_ENS_FIELD', 'METPLUS_NBRHD_PROB_DICT', 'METPLUS_NMEP_SMOOTH_DICT', @@ -65,15 +66,27 @@ def create_c_dict(self): ) # get input template/dir - template is required - c_dict['INPUT_TEMPLATE'] = self.config.getraw( + c_dict['FCST_INPUT_TEMPLATE'] = self.config.getraw( 'config', 'GEN_ENS_PROD_INPUT_TEMPLATE' ) - c_dict['INPUT_DIR'] = self.config.getdir('GEN_ENS_PROD_INPUT_DIR', '') + c_dict['FCST_INPUT_DIR'] = self.config.getdir('GEN_ENS_PROD_INPUT_DIR', + '') - if not c_dict['INPUT_TEMPLATE']: + if not c_dict['FCST_INPUT_TEMPLATE']: self.log_error('GEN_ENS_PROD_INPUT_TEMPLATE must be set') + # not all input files are mandatory to be found + c_dict['MANDATORY'] = False + + # fill inputs that are not found with fake path to note it is missing + c_dict['FCST_FILL_MISSING'] = True + + # number of expected ensemble members + c_dict['N_MEMBERS'] = ( + self.config.getint('config', 'GEN_ENS_PROD_N_MEMBERS') + ) + # get ctrl (control) template/dir - optional c_dict['CTRL_INPUT_TEMPLATE'] = self.config.getraw( 'config', @@ -195,68 +208,30 @@ def run_at_time_once(self, time_info): @param time_info dictionary containing timing information """ + # add config file to arguments + config_file = do_string_sub(self.c_dict['CONFIG_FILE'], **time_info) + self.args.append(f"-config {config_file}") + if not self.find_field_info(time_info): return False - if not self.find_input_files(time_info): + if not self.find_input_files_ensemble(time_info): return False if not self.find_and_check_output_file(time_info): return False - # add config file to arguments - config_file = do_string_sub(self.c_dict['CONFIG_FILE'], **time_info) - self.args.append(f"-config {config_file}") - - if not self.find_ctrl_file(time_info): - return False - # set environment variables that are passed to the MET config self.set_environment_variables(time_info) return self.build() - def find_input_files(self, time_info): - """! Get a list of all input files - - @param time_info dictionary containing timing information - @returns True on success - """ - input_files = self.find_data(time_info, return_list=True) - if not input_files: - self.log_error("Could not find any input files") - return False - - # write file that contains list of ensemble files - list_filename = (f"{time_info['init_fmt']}_" - f"{time_info['lead_hours']}_gen_ens_prod.txt") - list_file = self.write_list_file(list_filename, input_files) - if not list_file: - self.log_error("Could not write filelist file") - return False - - self.infiles.append(list_file) - - return True - - def find_ctrl_file(self, time_info): - """! Find optional ctrl (control) file if requested + def find_field_info(self, time_info): + """! parse var list for ENS fields @param time_info dictionary containing timing information - @returns True on success or if ctrl not requested + @returns True if successful, False if something went wrong """ - if not self.c_dict['CTRL_INPUT_TEMPLATE']: - return True - - input_file = self.find_data(time_info, data_type='CTRL') - if not input_file: - return False - - self.args.append(f'-ctrl {input_file}') - return True - - def find_field_info(self, time_info): - # parse var list for ENS fields ensemble_var_list = sub_var_list(self.c_dict['ENS_VAR_LIST_TEMP'], time_info) all_fields = [] diff --git a/parm/met_config/GenEnsProdConfig_wrapped b/parm/met_config/GenEnsProdConfig_wrapped index 59c794310a..2da107e1d4 100644 --- a/parm/met_config/GenEnsProdConfig_wrapped +++ b/parm/met_config/GenEnsProdConfig_wrapped @@ -53,10 +53,10 @@ ens = { ${METPLUS_ENS_FILE_TYPE} //ens_thresh = - ${METPLUS_ENS_ENS_THRESH} + ${METPLUS_ENS_THRESH} //vld_thresh = - ${METPLUS_ENS_VLD_THRESH} + ${METPLUS_VLD_THRESH} //field = ${METPLUS_ENS_FIELD} diff --git a/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf b/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf index 757db28a04..b545614bde 100644 --- a/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf +++ b/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf @@ -22,12 +22,22 @@ LOOP_ORDER = processes GEN_ENS_PROD_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst +# ensemble gep4 does not exist in sample input data GEN_ENS_PROD_INPUT_TEMPLATE = - {init?fmt=%Y%m%d%H}/*gep*/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib + {init?fmt=%Y%m%d%H}/arw-fer-gep1/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib, + {init?fmt=%Y%m%d%H}/arw-sch-gep2/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib, + {init?fmt=%Y%m%d%H}/arw-tom-gep3/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib, + {init?fmt=%Y%m%d%H}/arw-tom-gep4/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib, + {init?fmt=%Y%m%d%H}/arw-fer-gep5/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib, + {init?fmt=%Y%m%d%H}/arw-sch-gep6/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib, + {init?fmt=%Y%m%d%H}/arw-tom-gep7/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib GEN_ENS_PROD_CTRL_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst GEN_ENS_PROD_CTRL_INPUT_TEMPLATE = - {init?fmt=%Y%m%d%H}/arw-tom-gep3/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib + {init?fmt=%Y%m%d%H}/arw-fer-gep1/d01_{init?fmt=%Y%m%d%H}_{lead?fmt=%3H}00.grib + +# there are 7 ensembles but 1 is used as control, so specify 6 members +GEN_ENS_PROD_N_MEMBERS = 6 GEN_ENS_PROD_OUTPUT_DIR = {OUTPUT_BASE}/gen_ens_prod GEN_ENS_PROD_OUTPUT_TEMPLATE = gen_ens_prod_{valid?fmt=%Y%m%d_%H%M%S}V_ens.nc @@ -78,7 +88,7 @@ ENS_VAR5_THRESH = >=5.0 # GEN_ENS_PROD_CAT_THRESH = # GEN_ENS_PROD_NC_VAR_STR = -# GEN_ENS_PROD_ENS_THRESH = 1.0 +GEN_ENS_PROD_ENS_THRESH = 0.8 # GEN_ENS_PROD_VLD_THRESH = 1.0 # GEN_ENS_PROD_NBRHD_PROB_WIDTH = 5 From fd90ba937b86e4958112dd90f60df04865b198e9 Mon Sep 17 00:00:00 2001 From: lisagoodrich <33230218+lisagoodrich@users.noreply.github.com> Date: Tue, 16 Nov 2021 14:55:57 -0700 Subject: [PATCH 14/43] Feature 1049 statistics list (#1271) * first attempt at a table #1049 * 2nd attempt, smaller, different formatting table #1049 * trying to get rid of git error message by adding this to the index #1049 * alignment #1049 * trying to text wrap in a table #1049 * attempting to create text wrapping in tables #1049 * removing role, trying to wrap text #1049 * removing html break, trying to wrap text #1049 * adding code to wrap text #1049 * removing code to wrap text #1049 * fixing naming typo #1049 * hitting returns in table #1049 * removing typo file, removing line breaks #1049 * attempting a simple table for text wrapping #1049 * attempting to bold header row in simple table #1049 * simple table line break attempt #1049 * simple table line break attempt #2 #1049 * simple table line break attempt #3 #1049 * trying another | #1049 * 2 trying another | #1049 * 3 trying another | #1049 * trying a blank line #1049 * trying reformating grid #1049 * 2 trying reformating grid #1049 * 3 trying reformating grid #1049 * 4 trying reformating grid #1049 * adding in one more table row #1049 * adding more text #1049 * trying glossary format #1049 * 2 trying glossary format #1049 * 3 trying glossary format #1049 * 4 trying glossary format #1049 * adding glossary format 2D objects #1049 * fixing glossary format 2D objects #1049 * adding glossary items #1049 * fixing glossary format #1049 * hopefully all examples are working #1049 * changing reference to tool * removing 2D object examples * removing the unwrapped table * adding ACC into the example * fixing formatting #1049 * fixing formatting take 2 #1049 * fixing formatting take 3 #1049 * bolding and language change #1049 * fixing spacing #1049 * fixing spacing again #1049 * changing title #1049 * trying superscript #1049 * superscript glossary #1049 * removing quotes #1049 * fixing spacing #1049 * Tara has decided to go with the glossary format. Removing table example. #1049 * Adding 2D objects #1049 * 2D objects fix #1049 * 2D objects fix attempt 2 #1049 * 2D objects fix attempt 3 #1049 * 2D objects fix attempt 4 #1049 * 2D objects fix attempt 5 #1049 * 2D objects fix attempt 7 #1049 * 2D objects fix attempt 8 #1049 * ABR added #1049 * adding remaining AB items to list #1049 * adding remaining ACC_ items to list #1049 * ADLAND & AFSS entries #1049 * A entries #1049 * fixing formatting #1049 * fixing formatting #2 #1049 * removing new entries to make it run #1049 * adding a couple back in #1049 * adding a couple more back in #1049 * tool key at the bottom #1049 * spacing changes #1049 * spacing changes another try #1049 * fixing spacing #1049 * fixing spacing attempt 2 #1049 * fixing spacing attempt 3 #1049 * through AREA items #1049 * AMODEL listed twice but it's not #1049 * AMODEL listed twice but it's not #2 #1049 * AMODEL added question marks so it will run #1049 * final A entries #1049 * naming the glossary statistics in hopes of not conflicting with original glossary #1049 * removing glossary statistics space #1049 * Testing multiple glossary names * Per #1067, working on attempts to have multiple glossaries with the same term * Per #1067, working on attempts to have multiple glossaries with the same term * Per #1067, working on attempts to have multiple glossaries with the same term * Per #1067, removing code with attempts at mutiple glossaries * Per #1067, adding back three question marks due to duplicate term * paring down list, adding a table for review * problems with table * still trying to get it to publish * still trying to get a second line in the table * still trying to get a second line in the table 2 * testing different formatting * testing different formatting * trying to comment out the glossary inner workings * formatting again * line breaks in table #1049 * adding in some more for an example #1049 * adding new entries in through AREA #1049 * fixing line breaks #1049 * fixing line breaks #1049 #2 * fixing line breaks and warning messages #1049 * fixing line breaks #1049 * adding AREA_RATIO through ASPECT_DIFF * fixing typo #1049 * fixing typo #1049 * fixing typo #1049 take 2 * adding AXIS_ANG to BCMSE #1049 * adding spacing #1049 * adding spacing removing commas #1049 * removing comma #1049 * BOUNDARY_DIST thru BSS_SMPL #1049 * BOUNDARY_DIST splitting across 2 lines #1049 * cleaning up typos #1049 * calibration thru centriod_dist #1049 * centriod_lat thru centroid_y #1049 * fixing spacing #1049 * removing test glossary #1049 * climo_mean thru crtk_err #1049 * fixing crtk_err spacing #1049 * fixing spacing #1049 * CSI to CURVATURE_Y #1049 * CURVATURE_X & Y spacing #1049 * DEV_CAT to DURATION_DIFF #1049 * EC_VALUE to F #1049 * F_RATE TO FBS #1049 * Fixing spacing #1049 * fcst_clus thru fcst_conv_radius #1049 * removing CTOP_PRS #1049 * fixing the order of tools for FBAR and FBIAS #1049 * fixing spacing #1049 * adding grid-stat to all point-stat entries #1049 * adding fixing spacing #1049 * adding fixing spacing take 2 #1049 * adding fixing spacing take 3 #1049 * adding fixing spacing take 4 #1049 * adding fixing spacing take 5 #1049 * adding fixing spacing take 6 #1049 * adding fixing spacing take 7 #1049 * fixing spacing with a period take 7 #1049 * first attempt fcst_ #1049 * fixing typos #1049 * thru FOBAR #1049 * thru end of F #1049 * g thru h #1049 * i thru intensity #s #1049 * fixing typos #1049 * capturing example for Julie #1049 * thru k #1049 * fixing typos #1049 * thru L #1049 * thru MG #1049 * thru N_ENS #1049 * thru all N #1049 * fixing FBIAS alignment #1049 * fixing ME and MSE alignment #1049 * fixing ME alignment take 2 #1049 * thru OBS_E #1049 * fixing alignment #1049 * fixing alignment n_thresh #1049 * thru OBS_thresh #1049 * thru O #1049 * fixing OOBAR formating #1049 * thru PR_CORR #1049 * commented lines out with line total info #1049 * commented lines out with line total info take 2 #1049 * thru R #1049 * thru SPEED #1049 * thru S #1049 * thru T #1049 * thru U #1049 * thru V #1049 * thru V #1049 * thru Z #1049 * Update statistics_list.rst Tara is testing editing in UI * Update statistics_list.rst Updates through the A's * Update statistics_list.rst Cleaning up the A's * Update statistics_list.rst Standardizing MODE and MTD entries * Update statistics_list.rst Updating B's and C's * Update statistics_list.rst Testing adding TC-Stat and TCST to an entry * Update statistics_list.rst Clean up of a few A-Cs and then update of Ds * Update statistics_list.rst A few clean-ups and Es * Update statistics_list.rst Halfway through Fs... * Update statistics_list.rst A little clean up and the rest of Fs * Update statistics_list.rst G, H, I, Ks * Update statistics_list.rst L, M, Ns * Update statistics_list.rst A little clean-up and Os * Update statistics_list.rst A little clean-up and Os * Update statistics_list.rst Rs * Update statistics_list.rst S and Ts * Update statistics_list.rst The rest of the list * Update statistics_list.rst Removed Attr from Stat Type thru E * Update statistics_list.rst Remove Attr from Statistics Type through Gs * Update statistics_list.rst Remove Attr from Statistic Type through Rs * Update statistics_list.rst Remove Attr from Stat Type to the end * Update statistics_list.rst Cleaned up some Line Type typos * Update statistics_list.rst Still more Attr cleanup Co-authored-by: Julie Prestopnik Co-authored-by: TaraJensen --- docs/Users_Guide/index.rst | 1 + docs/Users_Guide/statistics_list.rst | 2098 ++++++++++++++++++++++++++ docs/_templates/theme_override.css | 16 + 3 files changed, 2115 insertions(+) create mode 100644 docs/Users_Guide/statistics_list.rst create mode 100644 docs/_templates/theme_override.css diff --git a/docs/Users_Guide/index.rst b/docs/Users_Guide/index.rst index acd989ed11..a1ab31578a 100644 --- a/docs/Users_Guide/index.rst +++ b/docs/Users_Guide/index.rst @@ -85,6 +85,7 @@ is sponsored by NSF. quicksearch glossary references + statistics_list .. Indices and tables diff --git a/docs/Users_Guide/statistics_list.rst b/docs/Users_Guide/statistics_list.rst new file mode 100644 index 0000000000..856f073ee1 --- /dev/null +++ b/docs/Users_Guide/statistics_list.rst @@ -0,0 +1,2098 @@ +****************************** +METplus Database of Statistics +****************************** + + +.. Number of characters per line: + Statistic Name - no more that 32 characters + METplus Name - no more than 17 characters + Statistic Type - no more than 19 characters + METplus Line Type - currently unlimited (approx 33 characters) + + +.. role:: raw-html(raw) + :format: html + +.. list-table:: Statistics List + :widths: auto + :header-rows: 1 + + * - Statistics :raw-html:`
` + Long Name + - METplus Name + - Statistic Type + - Tools + - METplus :raw-html:`
` + Line Type + * - Accuracy + - ACC + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + MODE + - CTS :raw-html:`
` + MCTS :raw-html:`
` + NBRCTS :raw-html:`
` + MODE cts + * - Asymptotic Fractions Skill Score + - AFSS + - Neighborhood + - Grid-Stat + - NBRCNT + * - Along track error (nm) + - ALTK_ERR + - Continuous + - TC-Pairs :raw-html:`
` + TC-Stat + - TCMPR :raw-html:`
` + TCST + * - Difference between the axis :raw-html:`
` + angles of two objects (in degrees) + - ANGLE_DIFF + - Diagnostic + - MODE + - MODE + * - Anomaly Correlation :raw-html:`
` + including mean error + - ANOM_CORR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Series-Analysis :raw-html:`
` + Stat-Analysis + - CNT + * - Uncentered Anomaly :raw-html:`
` + Correlation excluding mean :raw-html:`
` + error + - ANOM_CORR :raw-html:`
` _UNCNTR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Series-Analysis :raw-html:`
` + Stat-Analysis + - CNT + * - Object area (in grid squares) + - AREA + - Diagnostic + - MODE :raw-html:`
` + MTD + - MODE obj + * - Forecast object area :raw-html:`
` + divided by the observation :raw-html:`
` + object area (unitless) + - AREA_RATIO + - Diagnostic + - MODE + - MODE obj + * - Area of the object :raw-html:`
` + that meet the object :raw-html:`
` + definition threshold :raw-html:`
` + criteria (in grid squares) + - AREA_THRESH + - Diagnostic + - MODE + - MODE obj + * - Absolute value of :raw-html:`
` + the difference :raw-html:`
` + between the aspect :raw-html:`
` + ratios of two objects :raw-html:`
` + (unitless) + - ASPECT_DIFF + - Diagnostic + - MODE + - MODE obj + * - Object axis angle :raw-html:`
` + (in degrees) + - AXIS_ANG + - Diagnostic + - MODE :raw-html:`
` + MTD + - MTD obj + * - Difference in spatial :raw-html:`
` + axis plane angles + - AXIS_DIFF + - Diagnostic + - MTD + - MTD obj + * - Baddeley’s Delta Metric + - BADDELEY + - Distance Map + - Grid-Stat + - DMAP + * - Bias Adjusted Gilbert :raw-html:`
` + Skill Score + - BAGSS + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Base Rate + - BASER + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Wavelet-Stat :raw-html:`
` + MODE + - CTS :raw-html:`
` + ECLV :raw-html:`
` + MODE cts :raw-html:`
` + NBRCTCS :raw-html:`
` + PSTD :raw-html:`
` + PJC + * - Bias-corrected mean :raw-html:`
` + squared error + - BCMSE + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Ensemble-Stat + - CNT :raw-html:`
` + SSVAR + * - Minimum distance between :raw-html:`
` + the boundaries of two objects + - BOUNDARY :raw-html:`
` + _DIST + - Diagnostic + - MODE + - MODE obj + * - Brier Score + - BRIER + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Climatological Brier Score + - BRIERCL + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Brier Skill Score relative :raw-html:`
` + to sample climatology + - BSS + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Brier Skill Score relative :raw-html:`
` + to external climatology + - BSS_SMPL + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Calibration when forecast :raw-html:`
` + is between the ith and :raw-html:`
` + i+1th probability :raw-html:`
` + thresholds (repeated) + - CALIBRATION :raw-html:`
` + _i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PJC + * - Total great circle distance :raw-html:`
` + travelled by the 2D spatial :raw-html:`
` + centroid over the lifetime :raw-html:`
` + of the 3D object + - CDIST :raw-html:`
` + _TRAVELLED + - Diagnostic + - MTD + - MTD 3D obj + * - Distance between two :raw-html:`
` + objects centroids :raw-html:`
` + (in grid units) + - CENTROID :raw-html:`
` + _DIST + - Diagnostic + - MODE + - MODE obj + * - Latitude of centroid :raw-html:`
` + - CENTROID :raw-html:`
` + _LAT + - Diagnostic + - MTD :raw-html:`
` + MODE + - MTD 2D & 3D obj :raw-html:`
` + MODE obj + * - Longitude of centroid :raw-html:`
` + - CENTROID :raw-html:`
` + _LON + - Diagnostic + - MTD :raw-html:`
` + MODE + - MTD 2D & 3D obj :raw-html:`
` + MODE obj + * - Time coordinate of centroid + - CENTROID_T + - Diagnostic + - MTD + - MTD 3D obj + * - X coordinate of centroid :raw-html:`
` + - CENTROID_X + - Diagnostic + - MTD :raw-html:`
` + MODE + - MTD 2D & 3D obj :raw-html:`
` + MODE obj + * - Y coordinate of centroid :raw-html:`
` + - CENTROID_Y + - Diagnostic + - MTD :raw-html:`
` + MODE + - MTD 2D & 3D obj :raw-html:`
` + MODE obj + * - Climatological mean value + - CLIMO_MEAN + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Ensemble-Stat + - MPR :raw-html:`
` + ORANK + * - Climatological standard :raw-html:`
` + deviation value + - CLIMO_STDEV + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Ensemble-Stat + - MPR :raw-html:`
` + ORANK + * - Ratio of the difference :raw-html:`
` + between the area of an :raw-html:`
` + object and the area of :raw-html:`
` + its convex hull divided :raw-html:`
` + by the area of the :raw-html:`
` + complex hull (unitless) + - COMPLEXITY + - Diagnostic + - MODE + - MODE obj + * - Ratio of complexities of :raw-html:`
` + two objects defined as :raw-html:`
` + the lesser of the forecast :raw-html:`
` + complexity divided by the :raw-html:`
` + observation complexity or :raw-html:`
` + its reciprocal (unitless) + - COMPLEXITY :raw-html:`
` + _RATIO + - Diagnostic + - MODE + - MODE obj + * - Minimum distance between :raw-html:`
` + the convex hulls of two :raw-html:`
` + objects (in grid units) + - CONVEX_HULL :raw-html:`
` + _DIST + - Diagnostic + - MODE + - MODE obj + * - Continuous Ranked :raw-html:`
` + Probability Score :raw-html:`
` + (normal dist.) + - CRPS + - Ensemble + - Ensemble-Stat + - ECNT + * - Continuous Ranked :raw-html:`
` + Probability Score :raw-html:`
` + (empirical dist.) + - CRPS_EMP + - Ensemble + - Ensemble-Stat + - ECNT + * - Climatological Continuous :raw-html:`
` + Ranked Probability Score :raw-html:`
` + (normal dist.) + - CRPSCL + - Ensemble + - Ensemble-Stat + - ECNT + * - Climatological Continuous :raw-html:`
` + Ranked Probability Score :raw-html:`
` + (empirical dist.) + - CRPSCL_EMP + - Ensemble + - Ensemble-Stat + - ECNT + * - Continuous Ranked :raw-html:`
` + Probability Skill Score :raw-html:`
` + (normal dist.) + - CRPSS + - Ensemble + - Ensemble-Stat + - ECNT + * - Continuous Ranked :raw-html:`
` + Probability Skill Score :raw-html:`
` + (empirical dist.) + - CRPSS_EMP + - Ensemble + - Ensemble-Stat + - ECNT + * - Cross track error (nm) + - CRTK_ERR + - Continuous + - TC-Pairs :raw-html:`
` + TC-Stat + - TCMPR :raw-html:`
` + TCST + * - Critical Success Index + - CSI + - Categorical + - Point-Stat :raw-html:`
` + MODE cts :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + MODE :raw-html:`
` + MBRCTCS + * - Radius of curvature + - CURVATURE + - Diagnostic + - MODE + - MODE obj + * - Ratio of the curvature + - CURVATURE :raw-html:`
` + _RATIO + - Diagnostic + - MODE + - MODE obj + * - Center of curvature :raw-html:`
` + (in grid coordinates) + - CURVATURE :raw-html:`
` + _X + - Diagnostic + - MODE + - MODE obj + * - Center of curvature :raw-html:`
` + (in grid coordinates) + - CURVATURE :raw-html:`
` + _Y + - Diagnostic + - MODE + - MODE obj + * - Absolute value of :raw-html:`
` + DIR_ERR (see below) + - DIR_ABSERR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Signed angle between :raw-html:`
` + the directions of the :raw-html:`
` + average forecast and :raw-html:`
` + observed wind vectors + - DIR_ERR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Difference in object :raw-html:`
` + direction of movement + - DIRECTION :raw-html:`
` + _DIFF + - Diagnostic + - MTD + - MTD 3D obj + * - Difference in the :raw-html:`
` + lifetimes of the :raw-html:`
` + two objects + - DURATION :raw-html:`
` + _DIFF + - Diagnostic + - MTD + - MTD 3D obj + * - Expected correct rate :raw-html:`
` + used for MCTS HSS_EC + - EC_VALUE + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - MCTC + * - Extreme Dependency Index + - EDI + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Extreme Dependency Score + - EDS + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Mean of absolute value :raw-html:`
` + of forecast minus :raw-html:`
` + observed gradients + - EGBAR + - Continuous + - Grid-Stat + - GRAD + * - Object end time + - END_TIME + - Diagnostic + - MTD + - MTD 3D obj + * - Difference in object :raw-html:`
` + ending time steps + - END_TIME :raw-html:`
` + _DELTA + - Diagnostic + - MTD + - MTD 3D obj + * - The unperturbed :raw-html:`
` + ensemble mean value + - ENS_MEAN + - Ensemble + - Ensemble-Stat + - ORANK + * - The PERTURBED ensemble :raw-html:`
` + mean (e.g. with :raw-html:`
` + Observation Error). + - ENS_MEAN :raw-html:`
` + _OERR + - Ensemble + - Ensemble-Stat + - ORANK + * - Standard deviation of :raw-html:`
` + the error + - ESTDEV + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Ensemble-Stat + - CNT :raw-html:`
` + SSVAR + * - Forecast rate/event :raw-html:`
` + frequency + - F_RATE + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - FHO :raw-html:`
` + NBRCNT + * - Mean forecast wind speed + - F_SPEED :raw-html:`
` + _BAR + - Continous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Mean Forecast Anomaly + - FABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - SAL1L2 + * - False alarm ratio + - FAR + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + MODE + - CTS :raw-html:`
` + MODE :raw-html:`
` + NBRCTCS + * - Forecast mean + - FBAR + - Categorical + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + - SSVAR :raw-html:`
` + CNT :raw-html:`
` + SL1L2 :raw-html:`
` + VCNT + * - Length (speed) of the :raw-html:`
` + average forecast :raw-html:`
` + wind vector + - FBAR :raw-html:`
` + _SPEED + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Frequency Bias + - FBIAS + - Categorical + - Wavelet-Stat :raw-html:`
` + MODE :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + - ISC :raw-html:`
` + MODE :raw-html:`
` + CTS :raw-html:`
` + NBRCTCS :raw-html:`
` + DMAP + * - Fractions Brier Score + - FBS + - Continuous + - Grid-Stat + - NBRCNT + * - Number of forecast :raw-html:`
` + clusters + - fcst_clus + - Diagnostic + - MODE + - MODE obj + * - Number of points used to :raw-html:`
` + define the hull of all :raw-html:`
` + of the cluster forecast :raw-html:`
` + objects + - fcst_clus :raw-html:`
` + _hull + - Diagnostic + - MODE + - MODE obj + * - Forecast Cluster Convex :raw-html:`
` + Hull Point Latitude + - fcst_clus :raw-html:`
` + _hull_lat + - Diagnostic + - MODE + - MODE obj + * - Forecast Cluster Convex :raw-html:`
` + Hull Point Longitude + - fcst_clus :raw-html:`
` + _hull _lon + - Diagnostic + - MODE + - MODE obj + * - Number of Forecast :raw-html:`
` + Cluster Convex Hull Points + - fcst_clus :raw-html:`
` + _hull_npts + - Diagnostic + - MODE + - MODE obj + * - Forecast Cluster Convex :raw-html:`
` + Hull Starting Index + - fcst_clus :raw-html:`
` + _hull_start + - Diagnostic + - MODE + - MODE obj + * - Forecast Cluster Convex :raw-html:`
` + Hull Point X-Coordinate + - fcst_clus :raw-html:`
` + _hull_x + - Diagnostic + - MODE + - MODE obj + * - Forecast Cluster Convex :raw-html:`
` + Hull Point Y-Coordinate + - fcst_clus :raw-html:`
` + _hull_y + - Diagnostic + - MODE + - MODE obj + * - Forecast Object Raw :raw-html:`
` + Values + - fcst_obj :raw-html:`
` + _raw + - Diagnostic + - MODE + - MODE obj + * - Number of simple :raw-html:`
` + forecast objects + - fcst_simp + - Diagnostic + - MODE + - MODE obj + * - Number of points used :raw-html:`
` + to define the boundaries :raw-html:`
` + of all of the simple :raw-html:`
` + forecast objects + - fcst_simp :raw-html:`
` + _bdy + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple :raw-html:`
` + Boundary Latitude + - fcst_simp :raw-html:`
` + _bdy_lat + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple :raw-html:`
` + Boundary Longitude + - fcst_simp :raw-html:`
` + _bdy_lon + - Diagnostic + - MODE + - MODE obj + * - Number of Forecast :raw-html:`
` + Simple Boundary Points + - fcst_simp :raw-html:`
` + _bdy_npts + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple :raw-html:`
` + Boundary Starting Index + - fcst_simp :raw-html:`
` + _bdy_start + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple :raw-html:`
` + Boundary X-Coordinate + - fcst_simp :raw-html:`
` + _bdy_x + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple :raw-html:`
` + Boundary Y-Coordinate + - fcst_simp :raw-html:`
` + _bdy_y + - Diagnostic + - MODE + - MODE obj + * - Number of points used to :raw-html:`
` + define the hull of all :raw-html:`
` + of the simple forecast :raw-html:`
` + objects + - fcst_simp :raw-html:`
` + _hull + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple Convex :raw-html:`
` + Hull Point Latitude + - fcst_simp :raw-html:`
` + _hull_lat + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple Convex :raw-html:`
` + Hull Point Longitude + - fcst_simp :raw-html:`
` + _hull_lon + - Diagnostic + - MODE + - MODE obj + * - Number of Forecast :raw-html:`
` + Simple Convex Hull Points + - fcst_simp :raw-html:`
` + _hull_npts + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple Convex :raw-html:`
` + Hull Starting Index + - fcst_simp :raw-html:`
` + _hull_start + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple Convex :raw-html:`
` + Hull Point X-Coordinate + - fcst_simp :raw-html:`
` + _hull_x + - Diagnostic + - MODE + - MODE obj + * - Forecast Simple Convex :raw-html:`
` + Hull Point Y-Coordinate + - fcst_simp :raw-html:`
` + _hull_y + - Diagnostic + - MODE + - MODE obj + * - Number of thresholds :raw-html:`
` + applied to the forecast + - fcst :raw-html:`
` + _thresh :raw-html:`
` + _length + - Diagnostic + - MODE + - MODE obj + * - Number of thresholds :raw-html:`
` + applied to the forecast + - fcst_thresh :raw-html:`
` + _length + - Diagnostic + - MODE + - MODE obj + * - Direction of the average :raw-html:`
` + forecast wind vector + - FDIR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Forecast energy squared :raw-html:`
` + for this scale + - FENERGY + - + - Wavelet-Stat + - ISC + * - Mean Forecast Anomaly Squared + - FFABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - SAL1L2 + * - Average of forecast :raw-html:`
` + squared. + - FFBAR + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + SL1L2 + * - Mean of absolute value :raw-html:`
` + of forecast gradients + - FGBAR + - + - Grid-Stat + - GRAD + * - Ratio of forecast and :raw-html:`
` + observed gradients + - FGOG_RATIO + - + - Grid-Stat + - GRAD + * - Count of events in :raw-html:`
` + forecast category i and :raw-html:`
` + observation category j + - Fi_Oj + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - MCTC + * - Forecast mean + - FMEAN + - Continuous + - MODE :raw-html:`
` + Grid-Stat :raw-html:`
` + Point-Stat + - MODE :raw-html:`
` + NBRCTCS :raw-html:`
` + CTS + * - Number of forecast no :raw-html:`
` + and observation no + - FN_ON + - Categorical + - MODE :raw-html:`
` + Grid-Stat :raw-html:`
` + Point-Stat + - MODE :raw-html:`
` + NBRCTC :raw-html:`
` + CTC + * - Number of forecast no :raw-html:`
` + and observation yes + - FN_OY + - Categorical + - MODE :raw-html:`
` + Grid-Stat :raw-html:`
` + Point-Stat + - MODE :raw-html:`
` + NBRCTC :raw-html:`
` + CTC + * - Attributes for pairs of :raw-html:`
` + simple forecast and :raw-html:`
` + observation objects + - FNNN_ONNN + - Categorical + - MODE + - MODE obj + * - Average product of :raw-html:`
` + forecast-climo and :raw-html:`
` + observation-climo :raw-html:`
` + / Mean(f-c)*(o-c) + - FOABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - SAL1L2 + * - Average product of :raw-html:`
` + forecast and observation :raw-html:`
` + / Mean(f*o) + - FOBAR + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + SL1L2 + * - Pratt’s Figure of Merit :raw-html:`
` + from observation to :raw-html:`
` + forecast + - FOM_FO + - Diagnostic + - Grid-Stat + - DMAP + * - Maximum of FOM_FO :raw-html:`
` + and FOM_OF + - FOM_MAX + - Diagnostic + - Grid-Stat + - DMAP + * - Mean of FOM_FO :raw-html:`
` + and FOM_OF :raw-html:`
` + - FOM_MEAN + - Diagnostic + - Grid-Stat + - DMAP + * - Minimum of FOM_FO :raw-html:`
` + and FOM_OF + - FOM_MIN + - Diagnostic + - Grid-Stat + - DMAP + * - Pratt’s Figure of Merit :raw-html:`
` + from forecast to :raw-html:`
` + observation + - FOM_OF + - Diagnostic + - Grid-Stat + - DMAP + * - Number of tied forecast :raw-html:`
` + ranks used in computing :raw-html:`
` + Kendall’s tau statistic + - FRANK_TIES + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Root mean square forecast :raw-html:`
` + wind speed + - FS_RMS + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Fractions Skill Score :raw-html:`
` + - FSS + - Neighborhood + - Grid-Stat + - NBRCNT + * - Standard deviation of the :raw-html:`
` + error + - FSTDEV + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + CNT :raw-html:`
` + VCNT + * - Number of forecast events + - FY + - Categorical + - Grid-Stat + - DMAP + * - Number of forecast yes :raw-html:`
` + and observation no + - FY_ON + - Categorical + - MODE :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - MODE :raw-html:`
` + CTC :raw-html:`
` + NBRCTC + * - Number of forecast yes :raw-html:`
` + and observation yes + - FY_OY + - Categorical + - MODE :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - MODE :raw-html:`
` + CTC :raw-html:`
` + NBRCTC + * - Distance between the :raw-html:`
` + forecast and Best track :raw-html:`
` + genesis events (km) + - GEN_DIST + - Diagnostic + - TC-Gen + - GENMPR + * - Forecast minus Best track :raw-html:`
` + genesis time in HHMMSS :raw-html:`
` + format + - GEN_TDIFF + - Diagnostic + - TC-Gen + - GENMPR + * - Gerrity Score and :raw-html:`
` + bootstrap confidence limits + - GER + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - MCTS + * - Gilbert Skill Score + - GSS + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + MODE + - CTS :raw-html:`
` + NBRCTCS :raw-html:`
` + MODE + * - Hit rate + - H_RATE + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - FHO + * - Hausdorff Distance + - HAUSDORFF + - Diagnostic + - Grid-Stat + - DMAP + * - Hanssen and Kuipers :raw-html:`
` + Discriminant + - HK + - Categorical + - MODE :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - MODE cts :raw-html:`
` + MCTS :raw-html:`
` + CTS :raw-html:`
` + NBRCTS + * - Heidke Skill Score + - HSS + - Categorical + - MODE :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - MODE cts :raw-html:`
` + MCTS :raw-html:`
` + CTS :raw-html:`
` + NBRCTS + * - Heidke Skill Score :raw-html:`
` + user-specific expected :raw-html:`
` + correct + - HSS_EC + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - MCTS + * - Ignorance Score + - IGN + - Ensemble + - Ensemble-Stat + - ECNT + * - Best track genesis minus :raw-html:`
` + forecast initialization :raw-html:`
` + time in HHMMSS format + - INIT_TDIFF + - Diagnostic + - TC-Gen + - GENMPR + * - 10th, 25th, 50th, 75th, :raw-html:`
` + 90th, and user-specified :raw-html:`
` + percentiles of :raw-html:`
` + intensity of the raw :raw-html:`
` + field within the :raw-html:`
` + object or time slice + - INTENSITY :raw-html:`
` + _10, _25, :raw-html:`
` + _50, _75, :raw-html:`
` + _90, _NN + - Diagnostic + - MODE + - MODE obj + * - Sum of the intensities of :raw-html:`
` + the raw field within the :raw-html:`
` + object (variable units) + - INTENSITY :raw-html:`
` + _SUM + - Diagnostics + - MODE + - MODE obj + * - Total interest for this :raw-html:`
` + object pair + - INTEREST + - Diagnostic + - MTD :raw-html:`
` + MODE + - MTD 3D obj :raw-html:`
` + MODE obj + * - Intersection area of two :raw-html:`
` + objects (in grid squares) + - INTERSECT :raw-html:`
` + ION_AREA + - Diagnostic + - MODE + - MODE obj + * - Ratio of intersection area :raw-html:`
` + to the lesser of the :raw-html:`
` + forecast and observation :raw-html:`
` + object areas (unitless) + - INTERSECT :raw-html:`
` + ION_OVER :raw-html:`
` + _AREA + - Diagnostic + - MODE + - MODE obj + * - “Volume” of object :raw-html:`
` + intersection + - INTERSECT :raw-html:`
` + ION_VOLUME + - Diagnostic + - MTD + - MTD 3D obj + * - Interquartile Range :raw-html:`
` + - IQR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - The intensity scale :raw-html:`
` + skill score + - ISC + - + - Wavelet-Stat + - ISC + * - The scale at which all :raw-html:`
` + information following :raw-html:`
` + applies + - ISCALE + - + - Wavelet-Stat + - ISC + * - Kendall’s tau statistic + - KT_CORR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Dimension of the latitude + - LAT + - Diagnostic + - MODE + - MODE obj + * - Length of the :raw-html:`
` + enclosing rectangle + - LENGTH + - Diagnostic + - MODE + - MODE obj + * - Likelihood when forecast :raw-html:`
` + is between the ith and :raw-html:`
` + i+1th probability :raw-html:`
` + thresholds repeated + - LIKELIHOOD :raw-html:`
` + _i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PJC + * - Logarithm of the Odds Ratio + - LODDS + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Dimension of the longitude + - LON + - Diagnostic + - MODE + - MODE obj + * - The Median Absolute :raw-html:`
` + Deviation + - MAD + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Mean absolute error + - MAE + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT :raw-html:`
` + SAL1L2 :raw-html:`
` + SL1L2 + * - Magnitude & :raw-html:`
` + Multiplicative bias + - MBIAS + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + CNT + * - The Mean Error + - ME + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - ECNT :raw-html:`
` + SSVAR :raw-html:`
` + CNT + * - The Mean Error of the :raw-html:`
` + PERTURBED ensemble mean + - ME_OERR + - Continuous + - Ensemble-Stat + - ECNT + * - The square of the :raw-html:`
` + mean error (bias) + - ME2 + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Mean-error Distance from :raw-html:`
` + observation to forecast + - MED_FO + - Distance + - Grid-Stat + - DMAP + * - Maximum of MED_FO :raw-html:`
` + and MED_OF + - MED_MAX + - Distance + - Grid-Stat + - DMAP + * - Mean of MED_FO :raw-html:`
` + and MED_OF + - MED_MEAN + - Distance + - Grid-Stat + - DMAP + * - Minimum of MED_FO :raw-html:`
` + and MED_OF + - MED_MIN + - Distance + - Grid-Stat + - DMAP + * - Mean-error Distance from :raw-html:`
` + forecast to observation + - MED_OF + - Distance + - Grid-Stat + - DMAP + * - Mean of maximum of :raw-html:`
` + absolute values of :raw-html:`
` + forecast and observed :raw-html:`
` + gradients + - MGBAR + - + - Grid-Stat + - GRAD + * - Mean squared error + - MSE + - Continuous + - Ensemble-Stat :raw-html:`
` + Wavelet-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + ISC :raw-html:`
` + CNT :raw-html:`
` + * - The mean squared error :raw-html:`
` + skill + - MSESS + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Mean squared length of :raw-html:`
` + the vector difference :raw-html:`
` + between the forecast :raw-html:`
` + and observed winds + - MSVE + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Dimension of the :raw-html:`
` + contingency table & the :raw-html:`
` + total number of :raw-html:`
` + categories in each :raw-html:`
` + dimension + - N_CAT + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - MCTC :raw-html:`
` + MCTS + * - Number of cluster objects + - N_CLUS + - Diagnostic + - MODE + - MODE obj + * - Number of simple :raw-html:`
` + forecast objects + - N_FCST_SIMP + - Diagnostic + - MODE + - MODE obj + * - Number of simple :raw-html:`
` + observation objects + - N_OBS_SIMP + - Diagnostic + - MODE + - MODE obj + * - Observation rate + - O_RATE + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - NBRCNT :raw-html:`
` + FHO + * - Mean observed wind speed + - O_SPEED_BAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Mean Observation Anomaly + - OABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - SAL1L2 + * - Average observed value :raw-html:`
` + - OBAR + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` . + - SSVAR :raw-html:`
` + CNT :raw-html:`
` + SL1L2 :raw-html:`
` + VCNT + * - Length (speed) of the :raw-html:`
` + average observed wind :raw-html:`
` + vector + - OBAR_SPEED + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Number of observed :raw-html:`
` + clusters + - obs_clus + - Diagnostic + - MODE + - MODE obj + * - Number of points used to :raw-html:`
` + define the hull of all of :raw-html:`
` + the cluster observation :raw-html:`
` + objects + - obs_clus :raw-html:`
` + _hull + - Diagnostic + - MODE + - MODE obj + * - Observation Cluster Convex :raw-html:`
` + Hull Point Latitude + - obs_clus :raw-html:`
` + _hull_lat + - Diagnostic + - MODE + - MODE obj + * - Observation Cluster Convex :raw-html:`
` + Hull Point Longitude + - obs_clus :raw-html:`
` + _hull_lon + - Diagnostic + - MODE + - MODE obj + * - Number of Observation :raw-html:`
` + Cluster Convex Hull Points + - obs_clus :raw-html:`
` + _hull_npts + - Diagnostic + - MODE + - MODE obj + * - Observation Cluster Convex :raw-html:`
` + Hull Starting Index + - obs_clus :raw-html:`
` + _hull_start + - Diagnostic + - MODE + - MODE obj + * - Observation Cluster Convex :raw-html:`
` + Hull Point X-Coordinate + - obs_clus :raw-html:`
` + _hull_x + - Diagnostic + - MODE + - MODE obj + * - Observation Cluster Convex :raw-html:`
` + Hull Point Y-Coordinate + - obs_clus :raw-html:`
` + _hull_y + - Diagnostic + - MODE + - MODE obj + * - Number of simple :raw-html:`
` + observation objects + - obs_simp + - Diagnostic + - MODE + - MODE obj + * - Number of points used :raw-html:`
` + to define the boundaries :raw-html:`
` + of the simple observation :raw-html:`
` + objects + - obs_simp :raw-html:`
` + _bdy + - Diagnostic + - MODE + - MODE obj + * - Observation Simple :raw-html:`
` + Boundary Point Latitude + - obs_simp :raw-html:`
` + _bdy_lat + - Diagnostic + - MODE + - MODE obj + * - Observation Simple :raw-html:`
` + Boundary Point Longitude + - obs_simp :raw-html:`
` + _bdy_lon + - Diagnostic + - MODE + - MODE obj + * - Number of Observation :raw-html:`
` + Simple Boundary Points + - obs_simp :raw-html:`
` + _bdy_npts + - Diagnostic + - MODE + - MODE obj + * - Number of points used to :raw-html:`
` + define the hull of the :raw-html:`
` + simple observation objects + - obs_simp :raw-html:`
` + _hull + - Diagnostic + - MODE + - MODE obj + * - Number of Observation :raw-html:`
` + Simple Convex Hull Points + - obs_simp :raw-html:`
` + _hull_npts + - Diagnostic + - MODE + - MODE obj + * - Odds Ratio + - ODDS + - Categorical + - MODE :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - MODE :raw-html:`
` + CTS :raw-html:`
` + NBRCTS + * - Direction of the average :raw-html:`
` + observed wind vector + - ODIR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Observed energy squared :raw-html:`
` + for this scale + - OENERGY + - + - Wavelet-Stat + - ISC + * - Mean of absolute value :raw-html:`
` + of observed gradients + - OGBAR + - + - Grid-Stat + - GRAD + * - Number of observation :raw-html:`
` + when forecast is between :raw-html:`
` + the ith and i+1th :raw-html:`
` + probability thresholds + - ON_i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PTC + * - Number of observation :raw-html:`
` + when forecast is between :raw-html:`
` + the ith and i+1th :raw-html:`
` + probability thresholds + - ON_TP_i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PJC + * - Mean Squared :raw-html:`
` + Observation Anomaly + - OOABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - SAL1L2 + * - Average of observation :raw-html:`
` + squared + - OOBAR + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + SL1L2 :raw-html:`
` + * - Number of tied observation :raw-html:`
` + ranks used in computing :raw-html:`
` + Kendall’s tau statistic + - ORANK_TIES + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Odds Ratio Skill Score + - ORSS + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Root mean square observed :raw-html:`
` + wind speed + - OS_RMS + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Standard deviation :raw-html:`
` + of observations + - OSTDEV + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + CNT :raw-html:`
` + VCNT + * - Number of observation :raw-html:`
` + events + - OY + - Categorical + - Grid-Stat + - DMAP + * - Number of observation yes :raw-html:`
` + when forecast is between :raw-html:`
` + the ith and i+1th :raw-html:`
` + probability thresholds + - OY_i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PTC + * - Number of observation yes :raw-html:`
` + when forecast is between :raw-html:`
` + the ith and i+1th :raw-html:`
` + probability thresholds :raw-html:`
` + as a proportion of the :raw-html:`
` + total OY (repeated) + - OY_TP_i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PJC + * - Ratio of the nth percentile :raw-html:`
` + (INTENSITY_NN column) of :raw-html:`
` + intensity of the two :raw-html:`
` + objects + - PERCENTILE :raw-html:`
` + _INTENSITY :raw-html:`
` + _RATIO + - Diagnostic + - MODE + - MODE obj + * - Probability Integral :raw-html:`
` + Transform + - PIT + - Ensemble + - Ensemble-Stat + - ORANK + * - Probability of false :raw-html:`
` + detection + - PODF + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS + * - Probability of detecting no + - PODN + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + MODE + - CTS :raw-html:`
` + NBRCTCS :raw-html:`
` + MODE + * - Probability of detecting :raw-html:`
` + yes + - PODY + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + MODE + - CTS :raw-html:`
` + NBRCTCS :raw-html:`
` + MODE + * - Probability of detecting :raw-html:`
` + yes when forecast is :raw-html:`
` + greater than the ith :raw-html:`
` + probability thresholds + - PODY_i + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - PRC + * - Probability of false :raw-html:`
` + detection + - POFD + - Categorical + - MODE :raw-html:`
` + Grid-Stat + - MODE :raw-html:`
` + NBRCTCS + * - Probability of false :raw-html:`
` + detection when forecast is :raw-html:`
` + greater than the ith :raw-html:`
` + probability thresholds + - POFD_i + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - PRC + * - Pearson correlation :raw-html:`
` + coefficient + - PR_CORR + - Continuous + - Ensemble-Stat :raw-html:`
` + Point-Stat :raw-html:`
` + Grid-Stat + - SSVAR :raw-html:`
` + CNT :raw-html:`
` + * - Rank of the observation + - RANK + - Ensemble + - Ensemble-Stat + - ORANK + * - Count of observations :raw-html:`
` + with the i-th rank + - RANK_i + - Ensemble + - Ensemble-Stat + - RHIST + * - Number of ranks used in :raw-html:`
` + computing Kendall’s tau :raw-html:`
` + statistic + - RANKS + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Refinement when forecast :raw-html:`
` + is between the ith and :raw-html:`
` + i+1th probability :raw-html:`
` + thresholds (repeated) + - REFINEMENT :raw-html:`
` + _i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PJC + * - Reliability + - RELIABILITY + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Number of times the i-th :raw-html:`
` + ensemble member’s value :raw-html:`
` + was closest to the :raw-html:`
` + observation (repeated). :raw-html:`
` + When n members tie, :raw-html:`
` + 1/n is assigned to each :raw-html:`
` + member. + - RELP_i + - Ensemble + - Ensemble-Stat + - RELP + * - Resolution + - RESOLUTION + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Root mean squared error + - RMSE + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat :raw-html:`
` + Ensemble-Stat :raw-html:`
` + - CNT :raw-html:`
` + ECNT :raw-html:`
` + SSVAR + * - Root Mean Square Error :raw-html:`
` + of the PERTURBED :raw-html:`
` + ensemble mean + - RMSE_OERR + - Continuous + - Ensemble-Stat + - ECNT + * - Root mean squared forecast :raw-html:`
` + anomaly + - RMSFA + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Root mean squared :raw-html:`
` + observation anomaly + - RMSOA + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Square root of MSVE + - RMSVE + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Area under the receiver :raw-html:`
` + operating characteristic :raw-html:`
` + curve + - ROC_AUC + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Mean of the Brier Scores :raw-html:`
` + for each RPS threshold + - RPS + - Ensemble + - Ensemble-Stat + - RPS + * - Mean of the reliabilities :raw-html:`
` + for each RPS threshold + - RPS_REL + - Ensemble + - Ensemble-Stat + - RPS + * - Mean of the resolutions :raw-html:`
` + for each RPS threshold + - RPS_RES + - Ensemble + - Ensemble-Stat + - RPS + * - Mean of the uncertainties :raw-html:`
` + for each RPS threshold + - RPS_UNC + - Ensemble + - Ensemble-Stat + - RPS + * - Ranked Probability Skill :raw-html:`
` + Score relative to external :raw-html:`
` + climatology + - RPSS + - Ensemble + - Ensemble-Stat + - RPS + * - Ranked Probability Skill :raw-html:`
` + Score relative to sample :raw-html:`
` + climatology + - RPSS_SMPL + - Ensemble + - Ensemble-Stat + - RPS + * - S1 score + - S1 + - Continuous + - Grid-Stat + - GRAD + * - S1 score with respect to :raw-html:`
` + observed gradient + - S1_OG + - Continuous + - Grid-Stat + - GRAD + * - Symmetric Extremal :raw-html:`
` + Dependency Index + - SEDI + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Symmetric Extreme :raw-html:`
` + Dependency Score + - SEDS + - Categorical + - Point-Stat :raw-html:`
` + Grid-Stat + - CTS :raw-html:`
` + NBRCTS + * - Scatter Index + - SI + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Spearman’s rank :raw-html:`
` + correlation coefficient + - SP_CORR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - CNT + * - Spatial distance between :raw-html:`
` + (𝑥,𝑦)(x,y) coordinates of :raw-html:`
` + object spacetime centroid + - SPACE :raw-html:`
` + _CENTROID :raw-html:`
` + _DIST + - Diagnostics + - MTD + - MTD 3D obs + * - Absolute value of SPEED_ERR + - SPEED :raw-html:`
` + _ABSERR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Difference in object speeds + - SPEED_DELTA + - Diagnostics + - MTD + - MTD 3D obs + * - Difference between the :raw-html:`
` + length of the average :raw-html:`
` + forecast wind vector and :raw-html:`
` + the average observed wind :raw-html:`
` + vector (in the sense F - O) + - SPEED_ERR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Standard deviation :raw-html:`
` + of the mean of the :raw-html:`
` + UNPERTURBED ensemble + - SPREAD + - Ensemble + - Ensemble-Stat + - ECNT :raw-html:`
` + ORANK + * - Standard deviation :raw-html:`
` + of the mean of the :raw-html:`
` + PERTURBED ensemble + - SPREAD_OERR + - Ensemble + - Ensemble-Stat + - ECNT :raw-html:`
` + ORANK + * - Standard Deviation :raw-html:`
` + of unperturbed ensemble :raw-html:`
` + variance and the :raw-html:`
` + observation error variance + - SPREAD_PLUS :raw-html:`
` + _OERR + - Ensemble + - Ensemble-Stat + - ECNT :raw-html:`
` + ORANK + * - Difference in object :raw-html:`
` + starting time steps + - START_TIME :raw-html:`
` + _DELTA + - Diagnostic + - MTD + - MTD 3D obj + * - Symmetric difference of :raw-html:`
` + two objects :raw-html:`
` + (in grid squares) + - SYMMETRIC :raw-html:`
` + _DIFF + - Diagnostics + - MODE + - MODE obj + * - Difference in t index of :raw-html:`
` + object spacetime centroid + - TIME :raw-html:`
` + _CENTROID :raw-html:`
` + _DELTA + - Diagnostic + - MTD + - MTD 3D obj + * - Track error of adeck :raw-html:`
` + relative to bdeck (nm) + - TK_ERR + - Continuous + - TC-Pairs + - PROBRIRW + * - Track error of adeck :raw-html:`
` + relative to bdeck (nm) + - TK_ERR + - Continuous + - TC-Pairs + - TCMPR + * - Mean U-component :raw-html:`
` + Forecast Anomaly + - UFABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean U-component + - UFBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Uniform Fractions Skill :raw-html:`
` + Score + - UFSS + - Neighborhood + - Grid-Stat + - NBRCNT + * - Variability of :raw-html:`
` + Observations + - UNCERTAINTY + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - PSTD + * - Union area of :raw-html:`
` + two objects :raw-html:`
` + (in grid squares) + - UNION_AREA + - Diagnostic + - MODE + - MODE obj + * - Mean U-component :raw-html:`
` + Observation Anomaly + - UOABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean U-component :raw-html:`
` + Observation + - UOBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Mean U-component :raw-html:`
` + Squared :raw-html:`
` + Forecast Anomaly :raw-html:`
` + plus Squared :raw-html:`
` + Observation :raw-html:`
` + Anomaly + - UVFFABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean U-component :raw-html:`
` + Squared :raw-html:`
` + Forecast :raw-html:`
` + plus Squared :raw-html:`
` + Observation + - UVFFBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Mean((uf-uc)*(uo-uc)+ :raw-html:`
` + (vf-vc)*(vo-vc)) + - UVFOABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean(uf*uo+vf*vo) + - UVFOBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Mean((uo-uc)²+(vo-vc)²) + - UVOOABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean(uo²+vo²) + - UVOOBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Economic value of the :raw-html:`
` + base rate + - VALUE_BASER + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - ECLV + * - Relative value for the :raw-html:`
` + ith Cost/Loss ratio + - VALUE_i + - Probability + - Point-Stat :raw-html:`
` + Grid-Stat + - ECLV + * - Maximum variance + - VAR_MAX + - Ensemble + - Ensemble-Stat + - SSVAR + * - Average variance + - VAR_MEAN + - Ensemble + - Ensemble-Stat + - SSVAR + * - Minimum variance + - VAR_MIN + - Ensemble + - Ensemble-Stat + - SSVAR + * - Direction of the vector :raw-html:`
` + difference between the :raw-html:`
` + average forecast and :raw-html:`
` + average wind vectors + - VDIFF_DIR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Length (speed) of the :raw-html:`
` + vector difference between :raw-html:`
` + the average forecast and :raw-html:`
` + average observed wind :raw-html:`
` + vectors + - VDIFF_SPEED + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VCNT + * - Mean(vf-vc) + - VFABAR + - Continous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean(vf) + - VFBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Mean(vo-vc) + - VOABAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VAL1L2 + * - Mean(vo) + - VOBAR + - Continuous + - Point-Stat :raw-html:`
` + Grid-Stat + - VL1L2 + * - Integer count of the :raw-html:`
` + number of 3D “cells” :raw-html:`
` + in an object + - VOLUME + - Diagnostic + - MTD + - MTD 3D obj + * - Forecast object volume :raw-html:`
` + divided by observation :raw-html:`
` + object volume + - VOLUME :raw-html:`
` + _RATIO + - Diagnostic + - MTD + - MTD 3D obj + * - Width of the enclosing :raw-html:`
` + rectangle (in grid units) + - WIDTH + - Diagnostic + - MODE + - MODE obj + * - X component of :raw-html:`
` + object velocity + - X_DOT + - Diagnostic + - MTD + - MTD 3D obj + * - X component position :raw-html:`
` + error (nm) + - X_ERR + - Diagnostic + - TC-Pairs + - PROBRIRW + * - X component position :raw-html:`
` + error (nm) + - X_ERR + - Diagnostic + - TC-Pairs + - TCMPR + * - y component of :raw-html:`
` + object velocity + - Y_DOT + - Diagnostic + - MTD + - MTD 3D obj + * - Y component position :raw-html:`
` + error (nm) + - Y_ERR + - Diagnostic + - TC-Pairs + - PROBRIRW :raw-html:`
` + TCMPR + * - Zhu’s Measure from :raw-html:`
` + observation to forecast + - ZHU_FO + - Diagnostic + - Grid-Stat + - DMAP + * - Maximum of ZHU_FO :raw-html:`
` + and ZHU_OF + - ZHU_MAX + - Diagnostic + - Grid-Stat + - DMAP + * - Mean of ZHU_FO :raw-html:`
` + and ZHU_OF + - ZHU_MEAN + - Diagnostic + - Grid-Stat + - DMAP + * - Minimum of ZHU_FO :raw-html:`
` + and ZHU_OF + - ZHU_MIN + - Diagnostic + - Grid-Stat + - DMAP + * - Zhu’s Measure from :raw-html:`
` + forecast to observation + - ZHU_OF + - Diagnostic + - Grid-Stat + - DMAP diff --git a/docs/_templates/theme_override.css b/docs/_templates/theme_override.css new file mode 100644 index 0000000000..ab16bba14c --- /dev/null +++ b/docs/_templates/theme_override.css @@ -0,0 +1,16 @@ +/* Fix missing line-wrapping with Sphinx-RTD theme */ +/* https://github.com/platformio/platformio-docs/issues/5 */ + +/* override table width restrictions */ +@media screen and (min-width: 767px) { + + .wy-table-responsive table td { + /* !important prevents the common CSS stylesheets from overriding + this as on RTD they are loaded after this stylesheet */ + white-space: normal !important; + } + + .wy-table-responsive { + overflow: visible !important; + } +} \ No newline at end of file From 9e0f7e33f0e80459b6f665144a6d912b273c7947 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 16 Nov 2021 16:46:58 -0700 Subject: [PATCH 15/43] Feature 1263 v4.1.0 beta4 (#1277) --- docs/Users_Guide/release-notes.rst | 26 ++++++++++++++++++++++++++ metplus/VERSION | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/Users_Guide/release-notes.rst b/docs/Users_Guide/release-notes.rst index 5e4eb32a35..c2b53db183 100644 --- a/docs/Users_Guide/release-notes.rst +++ b/docs/Users_Guide/release-notes.rst @@ -33,6 +33,32 @@ When applicable, release notes are followed by the GitHub issue number which describes the bugfix, enhancement, or new feature: https://github.com/dtcenter/METplus/issues +METplus Version 4.1.0-beta4 Release Notes (2021-11-16) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* Enhancements: + + * **Create an Amazon AMI containing all METplus components** (`#506 `_) + * Added support for setting a dictionary value for time_summary.width (`#1252 `_) + * Added support for setting obs_quality_inc/exc in PointStat (`#1213 `_) + * Properly handle list values that include square braces (`#1212 `_) + * Reorganize the Cryosphere and Marine and Coastal use case categories into one group (`#1200 `_) + * Update wrapped MET config files to reference MET_TMP_DIR in tmp value (`#1101 `_) + * CyclonePlotter, create options to format output grid area to user-desired area (`#1091 `_) + * CyclonePlotter, connected lines run over the Prime Meridian (`#1000 `_) + * Add harmonic pre-processing to the RMM use case (`#1019 `_) + +* New Wrappers: + + * **IODA2NC** (`#1203 `_) + * **GenEnsProd** (`#1180 `_, `#1266 `_) + +* New Use Cases: + + * **IODA2NC** (`#1204 `_) + * **GenEnsProd** (`#1180 `_, `#1266 `_) + + METplus Version 4.1.0-beta3 Release Notes (2021-10-06) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/metplus/VERSION b/metplus/VERSION index 594faefc69..a08b93653b 100644 --- a/metplus/VERSION +++ b/metplus/VERSION @@ -1 +1 @@ -4.1.0-beta4-dev \ No newline at end of file +4.1.0-beta4 \ No newline at end of file From 801cc94e099bb1f01e0cd8dc0f39a23a6b1d2748 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 16 Nov 2021 16:47:51 -0700 Subject: [PATCH 16/43] update version to note development towards beta5 --- metplus/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplus/VERSION b/metplus/VERSION index a08b93653b..bcc7104f60 100644 --- a/metplus/VERSION +++ b/metplus/VERSION @@ -1 +1 @@ -4.1.0-beta4 \ No newline at end of file +4.1.0-beta5-dev \ No newline at end of file From 33ba9acf1625a24cee2ea727d6778de6c8986984 Mon Sep 17 00:00:00 2001 From: jprestop Date: Wed, 17 Nov 2021 08:24:23 -0700 Subject: [PATCH 17/43] Feature 934 release stage doc (#1235) * Per #934 add stages of the METplus release cycle. * Per #934, adding link to descriptions of the release cycle in the User's Guide. * Per #934, made corrections * Per #934, changed Beta and Release Candidate (rc) from bold to subsubsections. * Update index.rst Co-authored-by: Julie Prestopnik --- docs/Release_Guide/index.rst | 54 ++++++++++++++++++++++++++++-- docs/Users_Guide/release-notes.rst | 5 +++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/docs/Release_Guide/index.rst b/docs/Release_Guide/index.rst index 3f14c7882f..160439a6b1 100644 --- a/docs/Release_Guide/index.rst +++ b/docs/Release_Guide/index.rst @@ -2,10 +2,60 @@ Release Guide ============= -This METplus Release Guide provides detailed instructions for creating software releases from the METplus component repositories. +This METplus Release Guide provides detailed instructions for METplus +developers for creating software releases for the METplus component +repositories. **This Release Guide is intended for developers creating +releases and is not intended for users of the software.** + +.. _releaseCycleStages: + +Stages of the METplus Release Cycle +=================================== + +Development Release +------------------- + +Beta +^^^^ + +Beta releases are a pre-release of the software to give a larger group of +users the opportunity to test the recently incorporated new features, +enhancements, and bug fixes. Beta releases allow for continued +development and bug fixes before an official release. There are many +possible configurations of hardware and software that exist and installation +of beta releases allow for testing of potential conflicts. + +Release Candidate (rc) +^^^^^^^^^^^^^^^^^^^^^^ + +A release candidate is a version of the software that is nearly ready for +official release but may still have a few bugs. At this stage, all product +features have been designed, coded, and tested through one or more beta +cycles with no known bugs. It is code complete, meaning that no entirely +new source code will be added to this release. There may still be source +code changes to fix bugs, changes to documentation, and changes to test +cases or utilities. + +Official Release +---------------- + +An official release is a stable release and is basically the release +candidate, which has passed all tests. It is the version of the code that +has been tested as thoroughly as possible and is reliable enough to be +used in production. + +Bugfix Release +-------------- + +A bugfix release introduces no new features, but fixes bugs in previous +official releases and targets the most critical bugs affecting users. + +Instructions Summary +==================== + Instructions are provided for three types of software releases: -#. **Official Release** (e.g. vX.Y.Z) from the develop branch +#. **Official Release** (e.g. vX.Y.Z) from the develop branch (becomes the new main_vX.Y branch) #. **Bugfix Release** (e.g. vX.Y.Z) from the corresponding main_vX.Y branch diff --git a/docs/Users_Guide/release-notes.rst b/docs/Users_Guide/release-notes.rst index c2b53db183..00078ee855 100644 --- a/docs/Users_Guide/release-notes.rst +++ b/docs/Users_Guide/release-notes.rst @@ -1,6 +1,11 @@ METplus Release Notes ===================== +Users can view the :ref:`releaseCycleStages` section of +the Release Guide for descriptions of the development releases (including +beta releases and release candidates), official releases, and bugfix +releases for the METplus Components. + METplus Components Release Note Links ------------------------------------- From 5d319799ef97668c097842a64500d3423d2d429d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 6 Dec 2021 10:00:56 -0700 Subject: [PATCH 18/43] Feature 344 met util refactor (#1292) --- .github/jobs/run_diff_docker.py | 7 +- docs/Contributors_Guide/basic_components.rst | 16 +- .../test_string_template_substitution.py | 22 +- .../pytests/ascii2nc/test_ascii2nc_wrapper.py | 7 +- .../command_builder/test_command_builder.py | 183 +-- .../config_metplus/test_config_metplus.py | 964 ++++++++++- .../test_ensemble_stat_wrapper.py | 13 +- .../gen_ens_prod/test_gen_ens_prod_wrapper.py | 179 +-- .../grid_stat/test_grid_stat_wrapper.py | 12 +- .../pytests/ioda2nc/test_ioda2nc_wrapper.py | 6 +- .../pytests/met_config/test_met_config.py | 59 + .../test_met_dictionary_info.py | 29 - .../pytests/met_util/test_met_util.py | 1095 +------------ .../pytests/mode/test_mode_wrapper.py | 12 +- .../pytests/mtd/test_mtd_wrapper.py | 27 - .../pytests/pb2nc/test_pb2nc_wrapper.py | 8 +- .../point_stat/test_point_stat_wrapper.py | 26 +- .../pytests/tc_gen/test_tc_gen_wrapper.py | 6 +- metplus/util/__init__.py | 3 +- metplus/util/config_metplus.py | 1421 ++++++++++++++--- metplus/util/constants.py | 2 + {ci => metplus}/util/diff_util.py | 0 metplus/util/met_config.py | 710 ++++++++ metplus/util/met_dictionary_info.py | 110 -- metplus/util/met_util.py | 1243 +------------- metplus/util/string_template_substitution.py | 32 +- metplus/wrappers/ascii2nc_wrapper.py | 112 +- metplus/wrappers/command_builder.py | 803 +--------- metplus/wrappers/compare_gridded_wrapper.py | 27 +- metplus/wrappers/ensemble_stat_wrapper.py | 131 +- metplus/wrappers/extract_tiles_wrapper.py | 5 +- metplus/wrappers/gen_ens_prod_wrapper.py | 6 +- metplus/wrappers/grid_diag_wrapper.py | 7 +- metplus/wrappers/grid_stat_wrapper.py | 47 +- metplus/wrappers/ioda2nc_wrapper.py | 6 +- metplus/wrappers/make_plots_wrapper.py | 3 +- metplus/wrappers/mode_wrapper.py | 219 +-- metplus/wrappers/mtd_wrapper.py | 33 +- metplus/wrappers/pb2nc_wrapper.py | 90 +- metplus/wrappers/pcp_combine_wrapper.py | 5 +- metplus/wrappers/point_stat_wrapper.py | 54 +- metplus/wrappers/regrid_data_plane_wrapper.py | 8 +- metplus/wrappers/series_analysis_wrapper.py | 64 +- metplus/wrappers/stat_analysis_wrapper.py | 11 +- metplus/wrappers/tc_gen_wrapper.py | 12 +- metplus/wrappers/tc_pairs_wrapper.py | 76 +- metplus/wrappers/tc_stat_wrapper.py | 137 +- metplus/wrappers/tcrmw_wrapper.py | 158 +- 48 files changed, 3995 insertions(+), 4211 deletions(-) create mode 100644 internal_tests/pytests/met_config/test_met_config.py delete mode 100644 internal_tests/pytests/met_dictionary_info/test_met_dictionary_info.py create mode 100644 metplus/util/constants.py rename {ci => metplus}/util/diff_util.py (100%) create mode 100644 metplus/util/met_config.py delete mode 100644 metplus/util/met_dictionary_info.py diff --git a/.github/jobs/run_diff_docker.py b/.github/jobs/run_diff_docker.py index c6c3937434..85a3246a6a 100755 --- a/.github/jobs/run_diff_docker.py +++ b/.github/jobs/run_diff_docker.py @@ -13,9 +13,9 @@ import shutil GITHUB_WORKSPACE = os.environ.get('GITHUB_WORKSPACE') -# add ci/util to sys path to get diff utility +# add util directory to sys path to get diff utility diff_util_dir = os.path.join(GITHUB_WORKSPACE, - 'ci', + 'metplus', 'util') sys.path.insert(0, diff_util_dir) from diff_util import compare_dir @@ -23,9 +23,6 @@ TRUTH_DIR = '/data/truth' OUTPUT_DIR = '/data/output' DIFF_DIR = '/data/diff' -# DIFF_DIR = os.path.join(GITHUB_WORKSPACE, -# 'artifact', -# 'diff') def copy_diff_output(diff_files): """! Loop through difference output and copy files diff --git a/docs/Contributors_Guide/basic_components.rst b/docs/Contributors_Guide/basic_components.rst index e2a29ab574..86d3d57323 100644 --- a/docs/Contributors_Guide/basic_components.rst +++ b/docs/Contributors_Guide/basic_components.rst @@ -274,7 +274,7 @@ should be set. Add Support for MET Dictionary ------------------------------ -The handle_met_config_dict function can be used to easily set a MET config +The add_met_config_dict function can be used to easily set a MET config dictionary variable. The function takes 2 arguments: * dict_name: Name of the MET dictionary variable, i.e. distance_map. @@ -285,7 +285,7 @@ dictionary variable. The function takes 2 arguments: :: - self.handle_met_config_dict('fcst_genesis', { + self.add_met_config_dict('fcst_genesis', { 'vmax_thresh': 'thresh', 'mslp_thresh': 'thresh', }) @@ -319,7 +319,7 @@ a function is typically used to handle it. For example, this function is in CompareGriddedWrapper and is used by GridStat, PointStat, and EnsembleStat:: def handle_climo_cdf_dict(self): - self.handle_met_config_dict('climo_cdf', { + self.add_met_config_dict('climo_cdf', { 'cdf_bins': ('float', None, None, ['CLIMO_CDF_BINS']), 'center_bins': 'bool', 'write_bins': 'bool', @@ -333,26 +333,26 @@ the nickname 'CLIMO_CDF_BINS' allows the user to set the variable GRID_STAT_CLIMO_CDF_BINS instead. There are many MET config dictionaries that only contain beg and end to define -a window. A function in CommandBuilder called handle_met_config_window can be +a window. A function in CommandBuilder called add_met_config_window can be used to easily set these variable by only supplying the name of the MET dictionary variable. :: - def handle_met_config_window(self, dict_name): + def add_met_config_window(self, dict_name): """! Handle a MET config window dictionary. It is assumed that the dictionary only contains 'beg' and 'end' entries that are integers. @param dict_name name of MET dictionary """ - self.handle_met_config_dict(dict_name, { + self.add_met_config_dict(dict_name, { 'beg': 'int', 'end': 'int', }) This can be called from any wrapper, i.e. TCGen:: - self.handle_met_config_window('fcst_hr_window') + self.add_met_config_window('fcst_hr_window') This will check if TC_GEN_FCST_HR_WINDOW_BEGIN (or TC_GEN_FCST_HR_WINDOW_BEG) and TC_GEN_FCST_HR_WINDOW_END are set and override fcst_hr_window.beg and/or @@ -383,5 +383,5 @@ handle_climo_dict, handle_mask, and handle_interp_dict. if uses_field: items['field'] = ('string', 'remove_quotes') - self.handle_met_config_dict('interp', items) + self.add_met_config_dict('interp', items) diff --git a/internal_tests/pytests/StringTemplateSubstitution/test_string_template_substitution.py b/internal_tests/pytests/StringTemplateSubstitution/test_string_template_substitution.py index c45bba3820..b19a6c45f4 100644 --- a/internal_tests/pytests/StringTemplateSubstitution/test_string_template_substitution.py +++ b/internal_tests/pytests/StringTemplateSubstitution/test_string_template_substitution.py @@ -1,10 +1,11 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import pytest import logging import datetime +import os -from metplus.util import do_string_sub, parse_template +from metplus.util import do_string_sub, parse_template, get_time_from_file from metplus.util import get_tags,format_one_time_item, format_hms from metplus.util import add_to_dict, populate_match_dict, get_fmt_info @@ -595,3 +596,20 @@ def test_do_string_sub_no_recurse_no_missing(templ, expected_filename): basin=basin_regex, cyclone=cyclone_regex) assert(filename == expected_filename) + +@pytest.mark.parametrize( + 'filepath, template, expected_result', [ + (os.getcwd(), 'file.{valid?fmt=%Y%m%d%H}.ext', None), + ('file.2019020104.ext', 'file.{valid?fmt=%Y%m%d%H}.ext', datetime.datetime(2019, 2, 1, 4)), + ('filename.2019020104.ext', 'file.{valid?fmt=%Y%m%d%H}.ext', None), + ('file.2019020104.ext.gz', 'file.{valid?fmt=%Y%m%d%H}.ext', datetime.datetime(2019, 2, 1, 4)), + ('filename.2019020104.ext.gz', 'file.{valid?fmt=%Y%m%d%H}.ext', None), + ] +) +def test_get_time_from_file(filepath, template, expected_result): + result = get_time_from_file(filepath, template) + + if result is None: + assert expected_result is None + else: + assert result['valid'] == expected_result diff --git a/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py b/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py index 8db28911a4..b7c3b72767 100644 --- a/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py +++ b/internal_tests/pytests/ascii2nc/test_ascii2nc_wrapper.py @@ -173,7 +173,12 @@ def test_ascii2nc_wrapper(metplus_config, config_overrides, assert(all_commands[0][0] == expected_cmd) env_vars = all_commands[0][1] - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # check that environment variables were set properly + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert (match is not None) diff --git a/internal_tests/pytests/command_builder/test_command_builder.py b/internal_tests/pytests/command_builder/test_command_builder.py index 29fede8943..355cb66b87 100644 --- a/internal_tests/pytests/command_builder/test_command_builder.py +++ b/internal_tests/pytests/command_builder/test_command_builder.py @@ -10,7 +10,7 @@ import datetime from metplus.wrappers.command_builder import CommandBuilder from metplus.util import time_util -from metplus.util import METConfigInfo as met_config +from metplus.util import METConfig @pytest.mark.parametrize( @@ -380,19 +380,6 @@ def test_handle_description(metplus_config, config_overrides, expected_value): cbw.handle_description() assert cbw.env_var_dict.get('METPLUS_DESC', '') == expected_value -@pytest.mark.parametrize( - 'input, output', [ - ('', 'NONE'), - ('NONE', 'NONE'), - ('FCST', 'FCST'), - ('OBS', 'OBS'), - ('G002', '"G002"'), - ] -) -def test_format_regrid_to_grid(metplus_config, input, output): - cbw = CommandBuilder(metplus_config()) - assert cbw.format_regrid_to_grid(input) == output - @pytest.mark.parametrize( 'config_overrides, set_to_grid, expected_dict', [ ({}, True, {'REGRID_TO_GRID': 'NONE'}), @@ -499,25 +486,29 @@ def test_handle_regrid_new(metplus_config, config_overrides, expected_output): True, 'test_string_1 = value_1;'), ] ) -def test_set_met_config_string(metplus_config, mp_config_name, met_config_name, +def test_add_met_config_string(metplus_config, mp_config_name, met_config_name, c_dict_key, remove_quotes, expected_output): cbw = CommandBuilder(metplus_config()) # set some config variables to test cbw.config.set('config', 'TEST_STRING_1', 'value_1') - c_dict = {} + extra_args = {} + if remove_quotes: + extra_args['remove_quotes'] = True - cbw.set_met_config_string(c_dict, - mp_config_name, - met_config_name, - c_dict_key=c_dict_key, - remove_quotes=remove_quotes) key = c_dict_key if key is None: - key = met_config_name.upper() + key = met_config_name + key = key.upper() + + cbw.add_met_config(name=met_config_name, + data_type='string', + env_var_name=key, + metplus_configs=[mp_config_name], + extra_args=extra_args) - assert c_dict.get(key, '') == expected_output + assert cbw.env_var_dict.get(f'METPLUS_{key}', '') == expected_output @pytest.mark.parametrize( 'mp_config_name,met_config_name,c_dict_key,uppercase,expected_output, is_ok', [ @@ -547,7 +538,7 @@ def test_set_met_config_string(metplus_config, mp_config_name, met_config_name, True, '', False), ] ) -def test_set_met_config_bool(metplus_config, mp_config_name, met_config_name, +def test_add_met_config_bool(metplus_config, mp_config_name, met_config_name, c_dict_key, uppercase, expected_output, is_ok): cbw = CommandBuilder(metplus_config()) @@ -556,18 +547,22 @@ def test_set_met_config_bool(metplus_config, mp_config_name, met_config_name, cbw.config.set('config', 'TEST_BOOL_3', False) cbw.config.set('config', 'TEST_BOOL_4', 'chicken') - c_dict = {} + extra_args = {} + if not uppercase: + extra_args['uppercase'] = False - cbw.set_met_config_bool(c_dict, - mp_config_name, - met_config_name, - c_dict_key=c_dict_key, - uppercase=uppercase) key = c_dict_key if key is None: - key = met_config_name.upper() + key = met_config_name + key = key.upper() + + cbw.add_met_config(name=met_config_name, + data_type='bool', + env_var_name=key, + metplus_configs=[mp_config_name], + extra_args=extra_args) - assert c_dict.get(key, '') == expected_output + assert cbw.env_var_dict.get(f'METPLUS_{key}', '') == expected_output assert cbw.isOK == is_ok # int @@ -590,7 +585,7 @@ def test_set_met_config_bool(metplus_config, mp_config_name, met_config_name, '', False), ] ) -def test_set_met_config_int(metplus_config, mp_config_name, met_config_name, +def test_add_met_config_int(metplus_config, mp_config_name, met_config_name, c_dict_key, expected_output, is_ok): cbw = CommandBuilder(metplus_config()) @@ -599,17 +594,17 @@ def test_set_met_config_int(metplus_config, mp_config_name, met_config_name, cbw.config.set('config', 'TEST_INT_3', -4) cbw.config.set('config', 'TEST_INT_4', 'chicken') - c_dict = {} - - cbw.set_met_config_int(c_dict, - mp_config_name, - met_config_name, - c_dict_key=c_dict_key) key = c_dict_key if key is None: - key = met_config_name.upper() + key = met_config_name + key = key.upper() - assert c_dict.get(key, '') == expected_output + cbw.add_met_config(name=met_config_name, + data_type='int', + env_var_name=key, + metplus_configs=[mp_config_name]) + + assert cbw.env_var_dict.get(f'METPLUS_{key}', '') == expected_output assert cbw.isOK == is_ok @pytest.mark.parametrize( @@ -631,7 +626,7 @@ def test_set_met_config_int(metplus_config, mp_config_name, met_config_name, '', False), ] ) -def test_set_met_config_float(metplus_config, mp_config_name, met_config_name, +def test_add_met_config_float(metplus_config, mp_config_name, met_config_name, c_dict_key, expected_output, is_ok): cbw = CommandBuilder(metplus_config()) @@ -640,17 +635,17 @@ def test_set_met_config_float(metplus_config, mp_config_name, met_config_name, cbw.config.set('config', 'TEST_FLOAT_3', 4) cbw.config.set('config', 'TEST_FLOAT_4', 'chicken') - c_dict = {} - - cbw.set_met_config_float(c_dict, - mp_config_name, - met_config_name, - c_dict_key=c_dict_key) key = c_dict_key if key is None: - key = met_config_name.upper() + key = met_config_name + key = key.upper() + + cbw.add_met_config(name=met_config_name, + data_type='float', + env_var_name=key, + metplus_configs=[mp_config_name]) - assert c_dict.get(key, '') == expected_output + assert cbw.env_var_dict.get(f'METPLUS_{key}', '') == expected_output assert cbw.isOK == is_ok @pytest.mark.parametrize( @@ -678,7 +673,7 @@ def test_set_met_config_float(metplus_config, mp_config_name, met_config_name, 'test_thresh_6 = NA;', True), ] ) -def test_set_met_config_thresh(metplus_config, mp_config_name, met_config_name, +def test_add_met_config_thresh(metplus_config, mp_config_name, met_config_name, c_dict_key, expected_output, is_ok): cbw = CommandBuilder(metplus_config()) @@ -689,17 +684,18 @@ def test_set_met_config_thresh(metplus_config, mp_config_name, met_config_name, cbw.config.set('config', 'TEST_THRESH_5', '>CDP40&&<=CDP50') cbw.config.set('config', 'TEST_THRESH_6', 'NA') - c_dict = {} - - cbw.set_met_config_thresh(c_dict, - mp_config_name, - met_config_name, - c_dict_key=c_dict_key) key = c_dict_key if key is None: - key = met_config_name.upper() + key = met_config_name + key = key.upper() - assert c_dict.get(key, '') == expected_output + cbw.add_met_config(name=met_config_name, + env_var_name=key, + data_type='thresh', + metplus_configs=[mp_config_name]) + + print(f"KEY: {key}, ENV VARS: {cbw.env_var_dict}") + assert cbw.env_var_dict.get(f'METPLUS_{key}', '') == expected_output assert cbw.isOK == is_ok @pytest.mark.parametrize( @@ -727,7 +723,7 @@ def test_set_met_config_thresh(metplus_config, mp_config_name, met_config_name, True, 'test_list_4 = [value_1, value2];'), ] ) -def test_set_met_config_list(metplus_config, mp_config_name, met_config_name, +def test_add_met_config_list(metplus_config, mp_config_name, met_config_name, c_dict_key, remove_quotes, expected_output): cbw = CommandBuilder(metplus_config()) @@ -736,18 +732,23 @@ def test_set_met_config_list(metplus_config, mp_config_name, met_config_name, cbw.config.set('config', 'TEST_LIST_3', "'value_1', 'value2'") cbw.config.set('config', 'TEST_LIST_4', '"value_1", "value2"') - c_dict = {} + extra_args = {} + if remove_quotes: + extra_args['remove_quotes'] = True - cbw.set_met_config_list(c_dict, - mp_config_name, - met_config_name, - c_dict_key=c_dict_key, - remove_quotes=remove_quotes) key = c_dict_key if key is None: - key = met_config_name.upper() + key = met_config_name + + key = key.upper() - assert c_dict.get(key, '') == expected_output + cbw.add_met_config(name=met_config_name, + data_type='list', + env_var_name=key, + metplus_configs=[mp_config_name], + extra_args=extra_args) + print(f"KEY: {key}, ENV VARS: {cbw.env_var_dict}") + assert cbw.env_var_dict.get(f'METPLUS_{key}', '') == expected_output @pytest.mark.parametrize( 'mp_config_name,allow_empty,expected_output', [ @@ -761,42 +762,28 @@ def test_set_met_config_list(metplus_config, mp_config_name, met_config_name, ('TEST_LIST_2', True, ''), ] ) -def test_set_met_config_list_allow_empty(metplus_config, mp_config_name, +def test_add_met_config_list_allow_empty(metplus_config, mp_config_name, allow_empty, expected_output): cbw = CommandBuilder(metplus_config()) # set some config variables to test cbw.config.set('config', 'TEST_LIST_1', '') - c_dict = {} + extra_args = {} + if allow_empty: + extra_args['allow_empty'] = True met_config_name = mp_config_name.lower() - cbw.set_met_config_list(c_dict, - mp_config_name, - met_config_name, - allow_empty=allow_empty) - - assert c_dict.get(mp_config_name, '') == expected_output + cbw.add_met_config(name=met_config_name, + data_type='list', + metplus_configs=[mp_config_name], + extra_args=extra_args) -@pytest.mark.parametrize( - 'data_type, expected_function', [ - ('int', 'set_met_config_int'), - ('float', 'set_met_config_float'), - ('list', 'set_met_config_list'), - ('string', 'set_met_config_string'), - ('thresh', 'set_met_config_thresh'), - ('bool', 'set_met_config_bool'), - ('bad_name', None), - ] -) -def test_set_met_config_function(metplus_config, data_type, expected_function): - cbw = CommandBuilder(metplus_config()) - function_found = cbw.set_met_config_function(data_type) - function_name = function_found.__name__ if function_found else None - assert(function_name == expected_function) + assert cbw.env_var_dict.get(f'METPLUS_{mp_config_name}', '') == expected_output + #assert c_dict.get(mp_config_name, '') == expected_output -def test_handle_met_config_dict(metplus_config): +def test_add_met_config_dict(metplus_config): dict_name = 'fcst_hr_window' beg = -3 end = 5 @@ -813,12 +800,12 @@ def test_handle_met_config_dict(metplus_config): 'end': 'int', } - cbw.handle_met_config_dict(dict_name, items) + cbw.add_met_config_dict(dict_name, items) print(f"env_var_dict: {cbw.env_var_dict}") actual_value = cbw.env_var_dict.get('METPLUS_FCST_HR_WINDOW_DICT') assert actual_value == expected_value -def test_handle_met_config_window(metplus_config): +def test_add_met_config_window(metplus_config): dict_name = 'fcst_hr_window' beg = -3 end = 5 @@ -830,7 +817,7 @@ def test_handle_met_config_window(metplus_config): cbw = CommandBuilder(config) cbw.app_name = 'tc_gen' - cbw.handle_met_config_window(dict_name) + cbw.add_met_config_window(dict_name) print(f"env_var_dict: {cbw.env_var_dict}") actual_value = cbw.env_var_dict.get('METPLUS_FCST_HR_WINDOW_DICT') assert actual_value == expected_value @@ -848,7 +835,7 @@ def test_add_met_config(metplus_config): expected_value = f'valid_freq = {value};' assert cbw.env_var_dict['METPLUS_VALID_FREQ'] == expected_value -def test_handle_met_config_dict_nested(metplus_config): +def test_add_met_config_dict_nested(metplus_config): dict_name = 'outer' beg = -3 end = 5 @@ -876,6 +863,6 @@ def test_handle_met_config_dict_nested(metplus_config): }), } - cbw.handle_met_config_dict(dict_name, items) + cbw.add_met_config_dict(dict_name, items) print(f"env_var_dict: {cbw.env_var_dict}") assert cbw.env_var_dict.get('METPLUS_OUTER_DICT') == expected_value diff --git a/internal_tests/pytests/config_metplus/test_config_metplus.py b/internal_tests/pytests/config_metplus/test_config_metplus.py index 3fef44ce23..1c55fbc973 100644 --- a/internal_tests/pytests/config_metplus/test_config_metplus.py +++ b/internal_tests/pytests/config_metplus/test_config_metplus.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import pytest +import pprint import os +from datetime import datetime from metplus.util import config_metplus @@ -28,9 +30,965 @@ def test_get_default_config_list(): expected_new = [os.path.join(new_parm_base, item) for item in new_list] expected_both = [os.path.join(both_parm_base, item) for item in both_list] - actual_old = config_metplus.get_default_config_list(old_parm_base) - actual_new = config_metplus.get_default_config_list(new_parm_base) - actual_both = config_metplus.get_default_config_list(both_parm_base) + actual_old = config_metplus._get_default_config_list(old_parm_base) + actual_new = config_metplus._get_default_config_list(new_parm_base) + actual_both = config_metplus._get_default_config_list(both_parm_base) assert actual_old == expected_old assert actual_new == expected_new assert actual_both == expected_both + +@pytest.mark.parametrize( + 'regex,index,id,expected_result', [ + # 0: No ID + (r'^FCST_VAR(\d+)_NAME$', 1, None, + {'1': [None], + '2': [None], + '4': [None]}), + # 1: ID and index 2 + (r'(\w+)_VAR(\d+)_NAME', 2, 1, + {'1': ['FCST'], + '2': ['FCST'], + '4': ['FCST']}), + # 2: index 1, ID 2, multiple identifiers + (r'^FCST_VAR(\d+)_(\w+)$', 1, 2, + {'1': ['NAME', 'LEVELS'], + '2': ['NAME'], + '4': ['NAME']}), + # 3: command that StatAnalysis wrapper uses + (r'MODEL(\d+)$', 1, None, + {'1': [None], + '2': [None],}), + # 4: TCPairs conensus logic + (r'^TC_PAIRS_CONSENSUS(\d+)_(\w+)$', 1, 2, + {'1': ['NAME', 'MEMBERS', 'REQUIRED', 'MIN_REQ'], + '2': ['NAME', 'MEMBERS', 'REQUIRED', 'MIN_REQ']}), + ] +) +def test_find_indices_in_config_section(metplus_config, regex, index, + id, expected_result): + config = metplus_config() + config.set('config', 'FCST_VAR1_NAME', 'name1') + config.set('config', 'FCST_VAR1_LEVELS', 'level1') + config.set('config', 'FCST_VAR2_NAME', 'name2') + config.set('config', 'FCST_VAR4_NAME', 'name4') + config.set('config', 'MODEL1', 'model1') + config.set('config', 'MODEL2', 'model2') + + config.set('config', 'TC_PAIRS_CONSENSUS1_NAME', 'name1') + config.set('config', 'TC_PAIRS_CONSENSUS1_MEMBERS', 'member1') + config.set('config', 'TC_PAIRS_CONSENSUS1_REQUIRED', 'True') + config.set('config', 'TC_PAIRS_CONSENSUS1_MIN_REQ', '1') + config.set('config', 'TC_PAIRS_CONSENSUS2_NAME', 'name2') + config.set('config', 'TC_PAIRS_CONSENSUS2_MEMBERS', 'member2') + config.set('config', 'TC_PAIRS_CONSENSUS2_REQUIRED', 'True') + config.set('config', 'TC_PAIRS_CONSENSUS2_MIN_REQ', '2') + + + indices = config_metplus.find_indices_in_config_section(regex, config, + index_index=index, + id_index=id) + + pp = pprint.PrettyPrinter() + print(f'Indices:') + pp.pprint(indices) + + assert indices == expected_result + +@pytest.mark.parametrize( + 'conf_items, met_tool, expected_result', [ + ({'CUSTOM_LOOP_LIST': "one, two, three"}, '', ['one', 'two', 'three']), + ({'CUSTOM_LOOP_LIST': "one, two, three", + 'GRID_STAT_CUSTOM_LOOP_LIST': "four, five",}, 'grid_stat', ['four', 'five']), + ({'CUSTOM_LOOP_LIST': "one, two, three", + 'GRID_STAT_CUSTOM_LOOP_LIST': "four, five",}, 'point_stat', ['one', 'two', 'three']), + ({'CUSTOM_LOOP_LIST': "one, two, three", + 'ASCII2NC_CUSTOM_LOOP_LIST': "four, five",}, 'ascii2nc', ['four', 'five']), + # fails to read custom loop list for point2grid because there are underscores in name + ({'CUSTOM_LOOP_LIST': "one, two, three", + 'POINT_2_GRID_CUSTOM_LOOP_LIST': "four, five",}, 'point2grid', ['one', 'two', 'three']), + ({'CUSTOM_LOOP_LIST': "one, two, three", + 'POINT2GRID_CUSTOM_LOOP_LIST': "four, five",}, 'point2grid', ['four', 'five']), + ] +) +def test_get_custom_string_list(metplus_config, conf_items, met_tool, expected_result): + config = metplus_config() + for conf_key, conf_value in conf_items.items(): + config.set('config', conf_key, conf_value) + + assert(config_metplus.get_custom_string_list(config, met_tool) == expected_result) + +@pytest.mark.parametrize( + 'config_var_name, expected_indices, set_met_tool', [ + ('FCST_GRID_STAT_VAR1_NAME', ['1'], True), + ('FCST_GRID_STAT_VAR2_INPUT_FIELD_NAME', ['2'], True), + ('FCST_GRID_STAT_VAR3_FIELD_NAME', ['3'], True), + ('BOTH_GRID_STAT_VAR4_NAME', ['4'], True), + ('BOTH_GRID_STAT_VAR5_INPUT_FIELD_NAME', ['5'], True), + ('BOTH_GRID_STAT_VAR6_FIELD_NAME', ['6'], True), + ('FCST_VAR7_NAME', ['7'], False), + ('FCST_VAR8_INPUT_FIELD_NAME', ['8'], False), + ('FCST_VAR9_FIELD_NAME', ['9'], False), + ('BOTH_VAR10_NAME', ['10'], False), + ('BOTH_VAR11_INPUT_FIELD_NAME', ['11'], False), + ('BOTH_VAR12_FIELD_NAME', ['12'], False), + ] +) +def test_find_var_indices_fcst(metplus_config, + config_var_name, + expected_indices, + set_met_tool): + config = metplus_config() + data_types = ['FCST'] + config.set('config', config_var_name, "NAME1") + met_tool = 'grid_stat' if set_met_tool else None + var_name_indices = config_metplus.find_var_name_indices(config, + data_types=data_types, + met_tool=met_tool) + + assert(len(var_name_indices) == len(expected_indices)) + for actual_index in var_name_indices: + assert(actual_index in expected_indices) + +@pytest.mark.parametrize( + 'data_type, met_tool, expected_out', [ + ('FCST', None, ['FCST_', + 'BOTH_',]), + ('OBS', None, ['OBS_', + 'BOTH_',]), + ('FCST', 'grid_stat', ['FCST_GRID_STAT_', + 'BOTH_GRID_STAT_', + 'FCST_', + 'BOTH_', + ]), + ('OBS', 'extract_tiles', ['OBS_EXTRACT_TILES_', + 'BOTH_EXTRACT_TILES_', + 'OBS_', + 'BOTH_', + ]), + ('ENS', None, ['ENS_']), + ('DATA', None, ['DATA_']), + ('DATA', 'tc_gen', ['DATA_TC_GEN_', + 'DATA_']), + + ] +) +def test_get_field_search_prefixes(data_type, met_tool, expected_out): + assert(config_metplus.get_field_search_prefixes(data_type, + met_tool) == expected_out) + +@pytest.mark.parametrize( + 'item_list, extension, is_valid', [ + (['FCST'], 'NAME', False), + (['OBS'], 'NAME', False), + (['FCST', 'OBS'], 'NAME', True), + (['BOTH'], 'NAME', True), + (['FCST', 'OBS', 'BOTH'], 'NAME', False), + (['FCST', 'ENS'], 'NAME', False), + (['OBS', 'ENS'], 'NAME', False), + (['FCST', 'OBS', 'ENS'], 'NAME', True), + (['BOTH', 'ENS'], 'NAME', True), + (['FCST', 'OBS', 'BOTH', 'ENS'], 'NAME', False), + + (['FCST', 'OBS'], 'THRESH', True), + (['BOTH'], 'THRESH', True), + (['FCST', 'OBS', 'BOTH'], 'THRESH', False), + (['FCST', 'OBS', 'ENS'], 'THRESH', True), + (['BOTH', 'ENS'], 'THRESH', True), + (['FCST', 'OBS', 'BOTH', 'ENS'], 'THRESH', False), + + (['FCST'], 'OPTIONS', True), + (['OBS'], 'OPTIONS', True), + (['FCST', 'OBS'], 'OPTIONS', True), + (['BOTH'], 'OPTIONS', True), + (['FCST', 'OBS', 'BOTH'], 'OPTIONS', False), + (['FCST', 'ENS'], 'OPTIONS', True), + (['OBS', 'ENS'], 'OPTIONS', True), + (['FCST', 'OBS', 'ENS'], 'OPTIONS', True), + (['BOTH', 'ENS'], 'OPTIONS', True), + (['FCST', 'OBS', 'BOTH', 'ENS'], 'OPTIONS', False), + + (['FCST', 'OBS', 'BOTH'], 'LEVELS', False), + (['FCST', 'OBS'], 'LEVELS', True), + (['BOTH'], 'LEVELS', True), + (['FCST', 'OBS', 'ENS'], 'LEVELS', True), + (['BOTH', 'ENS'], 'LEVELS', True), + + ] +) +def test_is_var_item_valid(metplus_config, item_list, extension, is_valid): + conf = metplus_config() + assert(config_metplus.is_var_item_valid(item_list, '1', extension, conf)[0] == is_valid) + +@pytest.mark.parametrize( + 'item_list, configs_to_set, is_valid', [ + + (['FCST'], {'FCST_VAR1_LEVELS': 'A06', + 'OBS_VAR1_NAME': 'script_name.py something else'}, True), + (['FCST'], {'FCST_VAR1_LEVELS': 'A06', + 'OBS_VAR1_NAME': 'APCP'}, False), + (['OBS'], {'OBS_VAR1_LEVELS': '"(*,*)"', + 'FCST_VAR1_NAME': 'script_name.py something else'}, True), + (['OBS'], {'OBS_VAR1_LEVELS': '"(*,*)"', + 'FCST_VAR1_NAME': 'APCP'}, False), + + (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06', + 'OBS_VAR1_NAME': 'script_name.py something else'}, True), + (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06', + 'OBS_VAR1_NAME': 'APCP'}, False), + (['OBS', 'ENS'], {'OBS_VAR1_LEVELS': '"(*,*)"', + 'FCST_VAR1_NAME': 'script_name.py something else'}, True), + (['OBS', 'ENS'], {'OBS_VAR1_LEVELS': '"(*,*)"', + 'FCST_VAR1_NAME': 'APCP'}, False), + + (['FCST'], {'FCST_VAR1_LEVELS': 'A06, A12', + 'OBS_VAR1_NAME': 'script_name.py something else'}, False), + (['FCST'], {'FCST_VAR1_LEVELS': 'A06, A12', + 'OBS_VAR1_NAME': 'APCP'}, False), + (['OBS'], {'OBS_VAR1_LEVELS': '"(0,*,*)", "(1,*,*)"', + 'FCST_VAR1_NAME': 'script_name.py something else'}, False), + + (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06, A12', + 'OBS_VAR1_NAME': 'script_name.py something else'}, False), + (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06, A12', + 'OBS_VAR1_NAME': 'APCP'}, False), + (['OBS', 'ENS'], {'OBS_VAR1_LEVELS': '"(0,*,*)", "(1,*,*)"', + 'FCST_VAR1_NAME': 'script_name.py something else'}, False), + + ] +) +def test_is_var_item_valid_levels(metplus_config, item_list, configs_to_set, is_valid): + conf = metplus_config() + for key, value in configs_to_set.items(): + conf.set('config', key, value) + + assert(config_metplus.is_var_item_valid(item_list, '1', 'LEVELS', conf)[0] == is_valid) + +# search prefixes are valid prefixes to append to field info variables +# config_overrides are a dict of config vars and their values +# search_key is the key of the field config item to check +# expected_value is the variable that search_key is set to +@pytest.mark.parametrize( + 'search_prefixes, config_overrides, expected_value', [ + (['BOTH_', 'FCST_'], + {'FCST_VAR1_': 'fcst_var1'}, + 'fcst_var1' + ), + (['BOTH_', 'FCST_'], {}, None), + + (['BOTH_', 'FCST_'], + {'FCST_VAR1_': 'fcst_var1', + 'BOTH_VAR1_': 'both_var1'}, + 'both_var1' + ), + + (['BOTH_GRID_STAT_', 'FCST_GRID_STAT_'], + {'FCST_GRID_STAT_VAR1_': 'fcst_grid_stat_var1'}, + 'fcst_grid_stat_var1' + ), + (['BOTH_GRID_STAT_', 'FCST_GRID_STAT_'], {}, None), + (['BOTH_GRID_STAT_', 'FCST_GRID_STAT_'], + {'FCST_GRID_STAT_VAR1_': 'fcst_grid_stat_var1', + 'BOTH_GRID_STAT_VAR1_': 'both_grid_stat_var1'}, + 'both_grid_stat_var1' + ), + + (['ENS_'], + {'ENS_VAR1_': 'env_var1'}, + 'env_var1' + ), + (['ENS_'], {}, None), + + ] +) +def test_get_field_config_variables(metplus_config, + search_prefixes, + config_overrides, + expected_value): + config = metplus_config() + index = '1' + field_info_types = ['name', 'levels', 'thresh', 'options', 'output_names'] + for field_info_type in field_info_types: + for key, value in config_overrides.items(): + config.set('config', + f'{key}{field_info_type.upper()}', + value) + + field_configs = config_metplus.get_field_config_variables(config, + index, + search_prefixes) + + assert(field_configs.get(field_info_type) == expected_value) + +@pytest.mark.parametrize( + 'config_keys, field_key, expected_value', [ + (['NAME', + ], + 'name', 'NAME' + ), + (['NAME', + 'INPUT_FIELD_NAME', + ], + 'name', 'NAME' + ), + (['INPUT_FIELD_NAME', + ], + 'name', 'INPUT_FIELD_NAME' + ), + ([], 'name', None), + (['LEVELS', + ], + 'levels', 'LEVELS' + ), + (['LEVELS', + 'FIELD_LEVEL', + ], + 'levels', 'LEVELS' + ), + (['FIELD_LEVEL', + ], + 'levels', 'FIELD_LEVEL' + ), + ([], 'levels', None), + (['OUTPUT_NAMES', + ], + 'output_names', 'OUTPUT_NAMES' + ), + (['OUTPUT_NAMES', + 'OUTPUT_FIELD_NAME', + ], + 'output_names', 'OUTPUT_NAMES' + ), + (['OUTPUT_FIELD_NAME', + ], + 'output_names', 'OUTPUT_FIELD_NAME' + ), + ([], 'output_names', None), + ] +) +def test_get_field_config_variables_synonyms(metplus_config, + config_keys, + field_key, + expected_value): + config = metplus_config() + index = '1' + prefix = 'BOTH_REGRID_DATA_PLANE_' + for key in config_keys: + config.set('config', f'{prefix}VAR{index}_{key}', key) + + field_configs = config_metplus.get_field_config_variables(config, + index, + [prefix]) + + assert(field_configs.get(field_key) == expected_value) + +# field info only defined in the FCST_* variables +@pytest.mark.parametrize( + 'data_type, list_created', [ + (None, False), + ('FCST', True), + ('OBS', False), + ] +) +def test_parse_var_list_fcst_only(metplus_config, data_type, list_created): + conf = metplus_config() + conf.set('config', 'FCST_VAR1_NAME', "NAME1") + conf.set('config', 'FCST_VAR1_LEVELS', "LEVELS11, LEVELS12") + conf.set('config', 'FCST_VAR2_NAME', "NAME2") + conf.set('config', 'FCST_VAR2_LEVELS', "LEVELS21, LEVELS22") + + # this should not occur because OBS variables are missing + if config_metplus.validate_configuration_variables(conf, force_check=True)[1]: + assert(False) + + var_list = config_metplus.parse_var_list(conf, time_info=None, data_type=data_type) + + # list will be created if requesting just OBS, but it should not be created if + # nothing was requested because FCST values are missing + if list_created: + assert(var_list[0]['fcst_name'] == "NAME1" and \ + var_list[1]['fcst_name'] == "NAME1" and \ + var_list[2]['fcst_name'] == "NAME2" and \ + var_list[3]['fcst_name'] == "NAME2" and \ + var_list[0]['fcst_level'] == "LEVELS11" and \ + var_list[1]['fcst_level'] == "LEVELS12" and \ + var_list[2]['fcst_level'] == "LEVELS21" and \ + var_list[3]['fcst_level'] == "LEVELS22") + else: + assert(not var_list) + +# field info only defined in the OBS_* variables +@pytest.mark.parametrize( + 'data_type, list_created', [ + (None, False), + ('OBS', True), + ('FCST', False), + ] +) +def test_parse_var_list_obs(metplus_config, data_type, list_created): + conf = metplus_config() + conf.set('config', 'OBS_VAR1_NAME', "NAME1") + conf.set('config', 'OBS_VAR1_LEVELS', "LEVELS11, LEVELS12") + conf.set('config', 'OBS_VAR2_NAME', "NAME2") + conf.set('config', 'OBS_VAR2_LEVELS', "LEVELS21, LEVELS22") + + # this should not occur because FCST variables are missing + if config_metplus.validate_configuration_variables(conf, force_check=True)[1]: + assert(False) + + var_list = config_metplus.parse_var_list(conf, time_info=None, data_type=data_type) + + # list will be created if requesting just OBS, but it should not be created if + # nothing was requested because FCST values are missing + if list_created: + assert(var_list[0]['obs_name'] == "NAME1" and \ + var_list[1]['obs_name'] == "NAME1" and \ + var_list[2]['obs_name'] == "NAME2" and \ + var_list[3]['obs_name'] == "NAME2" and \ + var_list[0]['obs_level'] == "LEVELS11" and \ + var_list[1]['obs_level'] == "LEVELS12" and \ + var_list[2]['obs_level'] == "LEVELS21" and \ + var_list[3]['obs_level'] == "LEVELS22") + else: + assert(not var_list) + + +# field info only defined in the BOTH_* variables +@pytest.mark.parametrize( + 'data_type, list_created', [ + (None, 'fcst:obs'), + ('FCST', 'fcst'), + ('OBS', 'obs'), + ] +) +def test_parse_var_list_both(metplus_config, data_type, list_created): + conf = metplus_config() + conf.set('config', 'BOTH_VAR1_NAME', "NAME1") + conf.set('config', 'BOTH_VAR1_LEVELS', "LEVELS11, LEVELS12") + conf.set('config', 'BOTH_VAR2_NAME', "NAME2") + conf.set('config', 'BOTH_VAR2_LEVELS', "LEVELS21, LEVELS22") + + # this should not occur because BOTH variables are used + if not config_metplus.validate_configuration_variables(conf, force_check=True)[1]: + assert(False) + + var_list = config_metplus.parse_var_list(conf, time_info=None, data_type=data_type) + print(f'var_list:{var_list}') + for list_to_check in list_created.split(':'): + if not var_list[0][f'{list_to_check}_name'] == "NAME1" or \ + not var_list[1][f'{list_to_check}_name'] == "NAME1" or \ + not var_list[2][f'{list_to_check}_name'] == "NAME2" or \ + not var_list[3][f'{list_to_check}_name'] == "NAME2" or \ + not var_list[0][f'{list_to_check}_level'] == "LEVELS11" or \ + not var_list[1][f'{list_to_check}_level'] == "LEVELS12" or \ + not var_list[2][f'{list_to_check}_level'] == "LEVELS21" or \ + not var_list[3][f'{list_to_check}_level'] == "LEVELS22": + assert(False) + +# field info defined in both FCST_* and OBS_* variables +def test_parse_var_list_fcst_and_obs(metplus_config): + conf = metplus_config() + conf.set('config', 'FCST_VAR1_NAME', "FNAME1") + conf.set('config', 'FCST_VAR1_LEVELS', "FLEVELS11, FLEVELS12") + conf.set('config', 'FCST_VAR2_NAME', "FNAME2") + conf.set('config', 'FCST_VAR2_LEVELS', "FLEVELS21, FLEVELS22") + conf.set('config', 'OBS_VAR1_NAME', "ONAME1") + conf.set('config', 'OBS_VAR1_LEVELS', "OLEVELS11, OLEVELS12") + conf.set('config', 'OBS_VAR2_NAME', "ONAME2") + conf.set('config', 'OBS_VAR2_LEVELS', "OLEVELS21, OLEVELS22") + + # this should not occur because FCST and OBS variables are found + if not config_metplus.validate_configuration_variables(conf, force_check=True)[1]: + assert(False) + + var_list = config_metplus.parse_var_list(conf) + + assert(var_list[0]['fcst_name'] == "FNAME1" and \ + var_list[0]['obs_name'] == "ONAME1" and \ + var_list[1]['fcst_name'] == "FNAME1" and \ + var_list[1]['obs_name'] == "ONAME1" and \ + var_list[2]['fcst_name'] == "FNAME2" and \ + var_list[2]['obs_name'] == "ONAME2" and \ + var_list[3]['fcst_name'] == "FNAME2" and \ + var_list[3]['obs_name'] == "ONAME2" and \ + var_list[0]['fcst_level'] == "FLEVELS11" and \ + var_list[0]['obs_level'] == "OLEVELS11" and \ + var_list[1]['fcst_level'] == "FLEVELS12" and \ + var_list[1]['obs_level'] == "OLEVELS12" and \ + var_list[2]['fcst_level'] == "FLEVELS21" and \ + var_list[2]['obs_level'] == "OLEVELS21" and \ + var_list[3]['fcst_level'] == "FLEVELS22" and \ + var_list[3]['obs_level'] == "OLEVELS22") + +# VAR1 defined by FCST, VAR2 defined by OBS +def test_parse_var_list_fcst_and_obs_alternate(metplus_config): + conf = metplus_config() + conf.set('config', 'FCST_VAR1_NAME', "FNAME1") + conf.set('config', 'FCST_VAR1_LEVELS', "FLEVELS11, FLEVELS12") + conf.set('config', 'OBS_VAR2_NAME', "ONAME2") + conf.set('config', 'OBS_VAR2_LEVELS', "OLEVELS21, OLEVELS22") + + # configuration is invalid and parse var list should not give any results + assert(not config_metplus.validate_configuration_variables(conf, force_check=True)[1] and not config_metplus.parse_var_list(conf)) + +# VAR1 defined by OBS, VAR2 by FCST, VAR3 by both FCST AND OBS +@pytest.mark.parametrize( + 'data_type, list_len, name_levels', [ + (None, 0, None), + ('FCST', 4, ('FNAME2:FLEVELS21','FNAME2:FLEVELS22','FNAME3:FLEVELS31','FNAME3:FLEVELS32')), + ('OBS', 4, ('ONAME1:OLEVELS11','ONAME1:OLEVELS12','ONAME3:OLEVELS31','ONAME3:OLEVELS32')), + ] +) +def test_parse_var_list_fcst_and_obs_and_both(metplus_config, data_type, list_len, name_levels): + conf = metplus_config() + conf.set('config', 'OBS_VAR1_NAME', "ONAME1") + conf.set('config', 'OBS_VAR1_LEVELS', "OLEVELS11, OLEVELS12") + conf.set('config', 'FCST_VAR2_NAME', "FNAME2") + conf.set('config', 'FCST_VAR2_LEVELS', "FLEVELS21, FLEVELS22") + conf.set('config', 'FCST_VAR3_NAME', "FNAME3") + conf.set('config', 'FCST_VAR3_LEVELS', "FLEVELS31, FLEVELS32") + conf.set('config', 'OBS_VAR3_NAME', "ONAME3") + conf.set('config', 'OBS_VAR3_LEVELS', "OLEVELS31, OLEVELS32") + + # configuration is invalid and parse var list should not give any results + if config_metplus.validate_configuration_variables(conf, force_check=True)[1]: + assert(False) + + var_list = config_metplus.parse_var_list(conf, time_info=None, data_type=data_type) + + if len(var_list) != list_len: + assert(False) + + if data_type is None: + assert(len(var_list) == 0) + + if name_levels is not None: + dt_lower = data_type.lower() + expected = [] + for name_level in name_levels: + name, level = name_level.split(':') + expected.append({f'{dt_lower}_name': name, + f'{dt_lower}_level': level}) + + for expect, reality in zip(expected,var_list): + if expect[f'{dt_lower}_name'] != reality[f'{dt_lower}_name']: + assert(False) + + if expect[f'{dt_lower}_level'] != reality[f'{dt_lower}_level']: + assert(False) + + assert(True) + +# option defined in obs only +@pytest.mark.parametrize( + 'data_type, list_len', [ + (None, 0), + ('FCST', 2), + ('OBS', 0), + ] +) +def test_parse_var_list_fcst_only_options(metplus_config, data_type, list_len): + conf = metplus_config() + conf.set('config', 'FCST_VAR1_NAME', "NAME1") + conf.set('config', 'FCST_VAR1_LEVELS', "LEVELS11, LEVELS12") + conf.set('config', 'FCST_VAR1_THRESH', ">1, >2") + conf.set('config', 'OBS_VAR1_OPTIONS', "OOPTIONS11") + + # this should not occur because OBS variables are missing + if config_metplus.validate_configuration_variables(conf, force_check=True)[1]: + assert(False) + + var_list = config_metplus.parse_var_list(conf, time_info=None, data_type=data_type) + + assert(len(var_list) == list_len) + +@pytest.mark.parametrize( + 'met_tool, indices', [ + (None, {'1': ['FCST']}), + ('GRID_STAT', {'2': ['FCST']}), + ('ENSEMBLE_STAT', {}), + ] +) +def test_find_var_indices_wrapper_specific(metplus_config, met_tool, indices): + conf = metplus_config() + data_type = 'FCST' + conf.set('config', f'{data_type}_VAR1_NAME', "NAME1") + conf.set('config', f'{data_type}_GRID_STAT_VAR2_NAME', "GSNAME2") + + var_name_indices = config_metplus.find_var_name_indices(conf, data_types=[data_type], + met_tool=met_tool) + + assert(var_name_indices == indices) + +# ensure that the field configuration used for +# met_tool_wrapper/EnsembleStat/EnsembleStat.conf +# works as expected +def test_parse_var_list_ensemble(metplus_config): + config = metplus_config() + config.set('config', 'ENS_VAR1_NAME', 'APCP') + config.set('config', 'ENS_VAR1_LEVELS', 'A24') + config.set('config', 'ENS_VAR1_THRESH', '>0.0, >=10.0') + config.set('config', 'ENS_VAR2_NAME', 'REFC') + config.set('config', 'ENS_VAR2_LEVELS', 'L0') + config.set('config', 'ENS_VAR2_THRESH', '>35.0') + config.set('config', 'ENS_VAR2_OPTIONS', 'GRIB1_ptv = 129;') + config.set('config', 'ENS_VAR3_NAME', 'UGRD') + config.set('config', 'ENS_VAR3_LEVELS', 'Z10') + config.set('config', 'ENS_VAR3_THRESH', '>=5.0') + config.set('config', 'ENS_VAR4_NAME', 'VGRD') + config.set('config', 'ENS_VAR4_LEVELS', 'Z10') + config.set('config', 'ENS_VAR4_THRESH', '>=5.0') + config.set('config', 'ENS_VAR5_NAME', 'WIND') + config.set('config', 'ENS_VAR5_LEVELS', 'Z10') + config.set('config', 'ENS_VAR5_THRESH', '>=5.0') + config.set('config', 'FCST_VAR1_NAME', 'APCP') + config.set('config', 'FCST_VAR1_LEVELS', 'A24') + config.set('config', 'FCST_VAR1_THRESH', '>0.01, >=10.0') + config.set('config', 'FCST_VAR1_OPTIONS', ('ens_ssvar_bin_size = 0.1; ' + 'ens_phist_bin_size = 0.05;')) + config.set('config', 'OBS_VAR1_NAME', 'APCP') + config.set('config', 'OBS_VAR1_LEVELS', 'A24') + config.set('config', 'OBS_VAR1_THRESH', '>0.01, >=10.0') + config.set('config', 'OBS_VAR1_OPTIONS', ('ens_ssvar_bin_size = 0.1; ' + 'ens_phist_bin_size = 0.05;')) + time_info = {} + + expected_ens_list = [{'index': '1', + 'ens_name': 'APCP', + 'ens_level': 'A24', + 'ens_thresh': ['>0.0', '>=10.0']}, + {'index': '2', + 'ens_name': 'REFC', + 'ens_level': 'L0', + 'ens_thresh': ['>35.0']}, + {'index': '3', + 'ens_name': 'UGRD', + 'ens_level': 'Z10', + 'ens_thresh': ['>=5.0']}, + {'index': '4', + 'ens_name': 'VGRD', + 'ens_level': 'Z10', + 'ens_thresh': ['>=5.0']}, + {'index': '5', + 'ens_name': 'WIND', + 'ens_level': 'Z10', + 'ens_thresh': ['>=5.0']}, + ] + expected_var_list = [{'index': '1', + 'fcst_name': 'APCP', + 'fcst_level': 'A24', + 'fcst_thresh': ['>0.01', '>=10.0'], + 'fcst_extra': ('ens_ssvar_bin_size = 0.1; ' + 'ens_phist_bin_size = 0.05;'), + 'obs_name': 'APCP', + 'obs_level': 'A24', + 'obs_thresh': ['>0.01', '>=10.0'], + 'obs_extra': ('ens_ssvar_bin_size = 0.1; ' + 'ens_phist_bin_size = 0.05;') + + }, + ] + + ensemble_var_list = config_metplus.parse_var_list(config, time_info, + data_type='ENS') + + # parse optional var list for FCST and/or OBS fields + var_list = config_metplus.parse_var_list(config, time_info, + met_tool='ensemble_stat') + + pp = pprint.PrettyPrinter() + print(f'ENSEMBLE_VAR_LIST:') + pp.pprint(ensemble_var_list) + print(f'VAR_LIST:') + pp.pprint(var_list) + + assert(len(ensemble_var_list) == len(expected_ens_list)) + for actual_ens, expected_ens in zip(ensemble_var_list, expected_ens_list): + for key, value in expected_ens.items(): + assert(actual_ens.get(key) == value) + + assert(len(var_list) == len(expected_var_list)) + for actual_var, expected_var in zip(var_list, expected_var_list): + for key, value in expected_var.items(): + assert(actual_var.get(key) == value) + +def test_parse_var_list_series_by(metplus_config): + config = metplus_config() + config.set('config', 'BOTH_EXTRACT_TILES_VAR1_NAME', 'RH') + config.set('config', 'BOTH_EXTRACT_TILES_VAR1_LEVELS', 'P850, P700') + config.set('config', 'BOTH_EXTRACT_TILES_VAR1_OUTPUT_NAMES', + 'RH_850mb, RH_700mb') + + config.set('config', 'BOTH_SERIES_ANALYSIS_VAR1_NAME', 'RH_850mb') + config.set('config', 'BOTH_SERIES_ANALYSIS_VAR1_LEVELS', 'P850') + config.set('config', 'BOTH_SERIES_ANALYSIS_VAR2_NAME', 'RH_700mb') + config.set('config', 'BOTH_SERIES_ANALYSIS_VAR2_LEVELS', 'P700') + time_info = {} + + expected_et_list = [{'index': '1', + 'fcst_name': 'RH', + 'fcst_level': 'P850', + 'fcst_output_name': 'RH_850mb', + 'obs_name': 'RH', + 'obs_level': 'P850', + 'obs_output_name': 'RH_850mb', + }, + {'index': '1', + 'fcst_name': 'RH', + 'fcst_level': 'P700', + 'fcst_output_name': 'RH_700mb', + 'obs_name': 'RH', + 'obs_level': 'P700', + 'obs_output_name': 'RH_700mb', + }, + ] + expected_sa_list = [{'index': '1', + 'fcst_name': 'RH_850mb', + 'fcst_level': 'P850', + 'obs_name': 'RH_850mb', + 'obs_level': 'P850', + }, + {'index': '2', + 'fcst_name': 'RH_700mb', + 'fcst_level': 'P700', + 'obs_name': 'RH_700mb', + 'obs_level': 'P700', + }, + ] + + actual_et_list = config_metplus.parse_var_list(config, + time_info=time_info, + met_tool='extract_tiles') + + actual_sa_list = config_metplus.parse_var_list(config, + met_tool='series_analysis') + + pp = pprint.PrettyPrinter() + print(f'ExtractTiles var list:') + pp.pprint(actual_et_list) + print(f'SeriesAnalysis var list:') + pp.pprint(actual_sa_list) + + assert(len(actual_et_list) == len(expected_et_list)) + for actual_et, expected_et in zip(actual_et_list, expected_et_list): + for key, value in expected_et.items(): + assert(actual_et.get(key) == value) + + assert(len(actual_sa_list) == len(expected_sa_list)) + for actual_sa, expected_sa in zip(actual_sa_list, expected_sa_list): + for key, value in expected_sa.items(): + assert(actual_sa.get(key) == value) + +def test_parse_var_list_priority_fcst(metplus_config): + priority_list = ['FCST_GRID_STAT_VAR1_NAME', + 'FCST_GRID_STAT_VAR1_INPUT_FIELD_NAME', + 'FCST_GRID_STAT_VAR1_FIELD_NAME', + 'BOTH_GRID_STAT_VAR1_NAME', + 'BOTH_GRID_STAT_VAR1_INPUT_FIELD_NAME', + 'BOTH_GRID_STAT_VAR1_FIELD_NAME', + 'FCST_VAR1_NAME', + 'FCST_VAR1_INPUT_FIELD_NAME', + 'FCST_VAR1_FIELD_NAME', + 'BOTH_VAR1_NAME', + 'BOTH_VAR1_INPUT_FIELD_NAME', + 'BOTH_VAR1_FIELD_NAME', + ] + time_info = {} + + # loop through priority list, process, then pop first value off and + # process again until all items have been popped. + # This will check that list is in priority order + while(priority_list): + config = metplus_config() + for key in priority_list: + config.set('config', key, key.lower()) + + var_list = config_metplus.parse_var_list(config, time_info=time_info, + data_type='FCST', + met_tool='grid_stat') + + assert(len(var_list) == 1) + assert(var_list[0].get('fcst_name') == priority_list[0].lower()) + priority_list.pop(0) + +# test that if wrapper specific field info is specified, it only gets +# values from that list. All generic values should be read if no +# wrapper specific field info variables are specified +def test_parse_var_list_wrapper_specific(metplus_config): + conf = metplus_config() + conf.set('config', 'FCST_VAR1_NAME', "ENAME1") + conf.set('config', 'FCST_VAR1_LEVELS', "ELEVELS11, ELEVELS12") + conf.set('config', 'FCST_VAR2_NAME', "ENAME2") + conf.set('config', 'FCST_VAR2_LEVELS', "ELEVELS21, ELEVELS22") + conf.set('config', 'FCST_GRID_STAT_VAR1_NAME', "GNAME1") + conf.set('config', 'FCST_GRID_STAT_VAR1_LEVELS', "GLEVELS11, GLEVELS12") + + e_var_list = config_metplus.parse_var_list(conf, + time_info=None, + data_type='FCST', + met_tool='ensemble_stat') + + g_var_list = config_metplus.parse_var_list(conf, + time_info=None, + data_type='FCST', + met_tool='grid_stat') + + assert(len(e_var_list) == 4 and len(g_var_list) == 2 and + e_var_list[0]['fcst_name'] == "ENAME1" and + e_var_list[1]['fcst_name'] == "ENAME1" and + e_var_list[2]['fcst_name'] == "ENAME2" and + e_var_list[3]['fcst_name'] == "ENAME2" and + e_var_list[0]['fcst_level'] == "ELEVELS11" and + e_var_list[1]['fcst_level'] == "ELEVELS12" and + e_var_list[2]['fcst_level'] == "ELEVELS21" and + e_var_list[3]['fcst_level'] == "ELEVELS22" and + g_var_list[0]['fcst_name'] == "GNAME1" and + g_var_list[1]['fcst_name'] == "GNAME1" and + g_var_list[0]['fcst_level'] == "GLEVELS11" and + g_var_list[1]['fcst_level'] == "GLEVELS12") + +@pytest.mark.parametrize( + 'config_overrides, expected_results', [ + # 2 levels + ({'FCST_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {fcst_level}', + 'FCST_VAR1_LEVELS': 'P500,P250', + 'OBS_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {obs_level}', + 'OBS_VAR1_LEVELS': 'P500,P250', + }, + ['read_data.py TMP 20200201 P500', + 'read_data.py TMP 20200201 P250', + ]), + ({'BOTH_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {fcst_level}', + 'BOTH_VAR1_LEVELS': 'P500,P250', + }, + ['read_data.py TMP 20200201 P500', + 'read_data.py TMP 20200201 P250', + ]), + # no level but level specified in name + ({'FCST_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {fcst_level}', + 'OBS_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {obs_level}', + }, + ['read_data.py TMP 20200201 ', + ]), + # no level + ({'FCST_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d}', + 'OBS_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d}', + }, + ['read_data.py TMP 20200201', + ]), + # real example + ({'BOTH_VAR1_NAME': ('myscripts/read_nc2xr.py ' + 'mydata/forecast_file.nc4 TMP ' + '{valid?fmt=%Y%m%d_%H%M} {fcst_level}'), + 'BOTH_VAR1_LEVELS': 'P1000,P850,P700,P500,P250,P100', + }, + [('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' + ' P1000'), + ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' + ' P850'), + ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' + ' P700'), + ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' + ' P500'), + ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' + ' P250'), + ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' + ' P100'), + ]), + ] +) +def test_parse_var_list_py_embed_multi_levels(metplus_config, config_overrides, + expected_results): + config = metplus_config() + for key, value in config_overrides.items(): + config.set('config', key, value) + + time_info = {'valid': datetime(2020, 2, 1, 12, 25)} + var_list = config_metplus.parse_var_list(config, + time_info=time_info, + data_type=None) + assert(len(var_list) == len(expected_results)) + + for var_item, expected_result in zip(var_list, expected_results): + assert(var_item['fcst_name'] == expected_result) + + # run again with data type specified + var_list = config_metplus.parse_var_list(config, + time_info=time_info, + data_type='FCST') + assert(len(var_list) == len(expected_results)) + + for var_item, expected_result in zip(var_list, expected_results): + assert(var_item['fcst_name'] == expected_result) + + +@pytest.mark.parametrize( + 'input_list, expected_list', [ + ('Point2Grid', ['Point2Grid']), + # MET documentation syntax (with dashes) + ('Pcp-Combine, Grid-Stat, Ensemble-Stat', ['PCPCombine', + 'GridStat', + 'EnsembleStat']), + ('Point-Stat', ['PointStat']), + ('Mode, MODE Time Domain', ['MODE', + 'MTD']), + # actual tool name (lower case underscore) + ('point_stat, grid_stat, ensemble_stat', ['PointStat', + 'GridStat', + 'EnsembleStat']), + ('mode, mtd', ['MODE', + 'MTD']), + ('ascii2nc, pb2nc, regrid_data_plane', ['ASCII2NC', + 'PB2NC', + 'RegridDataPlane']), + ('pcp_combine, tc_pairs, tc_stat', ['PCPCombine', + 'TCPairs', + 'TCStat']), + ('gen_vx_mask, stat_analysis, series_analysis', ['GenVxMask', + 'StatAnalysis', + 'SeriesAnalysis']), + # old capitalization format + ('PcpCombine, Ascii2Nc, TcStat, TcPairs', ['PCPCombine', + 'ASCII2NC', + 'TCStat', + 'TCPairs']), + # remove MakePlots from list + ('StatAnalysis, MakePlots', ['StatAnalysis']), + ] +) +def test_get_process_list(metplus_config, input_list, expected_list): + conf = metplus_config() + conf.set('config', 'PROCESS_LIST', input_list) + process_list = config_metplus.get_process_list(conf) + output_list = [item[0] for item in process_list] + assert(output_list == expected_list) + +@pytest.mark.parametrize( + 'input_list, expected_list', [ + # no instances + ('Point2Grid', [('Point2Grid', None)]), + # one with instance one without + ('PcpCombine, GridStat(my_instance)', [('PCPCombine', None), + ('GridStat', 'my_instance')]), + # duplicate process, one with instance one without + ('TCStat, ExtractTiles, TCStat(for_series), SeriesAnalysis', ( + [('TCStat',None), + ('ExtractTiles',None), + ('TCStat', 'for_series'), + ('SeriesAnalysis',None),])), + # two processes, both with instances + ('mode(uno), mtd(dos)', [('MODE', 'uno'), + ('MTD', 'dos')]), + # lower-case names, first with instance, second without + ('ascii2nc(some_name), pb2nc', [('ASCII2NC', 'some_name'), + ('PB2NC', None)]), + # duplicate process, both with different instances + ('tc_stat(one), tc_pairs, tc_stat(two)', [('TCStat', 'one'), + ('TCPairs', None), + ('TCStat', 'two')]), + ] +) +def test_get_process_list_instances(metplus_config, input_list, expected_list): + conf = metplus_config() + conf.set('config', 'PROCESS_LIST', input_list) + output_list = config_metplus.get_process_list(conf) + assert(output_list == expected_list) \ No newline at end of file diff --git a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py index be6ee1acb4..1f864709a1 100644 --- a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py +++ b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py @@ -137,7 +137,8 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, ({'ENSEMBLE_STAT_REGRID_TO_GRID': 'FCST', }, - {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}'}), + {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}', + 'REGRID_TO_GRID': 'FCST'}), ({'ENSEMBLE_STAT_REGRID_METHOD': 'NEAREST', }, @@ -163,7 +164,8 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, }, {'METPLUS_REGRID_DICT': ('regrid = {to_grid = FCST;method = NEAREST;' 'width = 1;vld_thresh = 0.5;shape = SQUARE;}' - )}), + ), + 'REGRID_TO_GRID': 'FCST'}), ({'ENSEMBLE_STAT_CLIMO_MEAN_INPUT_TEMPLATE': '/some/path/climo/filename.nc', @@ -575,7 +577,6 @@ def test_ensemble_stat_single_field(metplus_config, config_overrides, f"{config_file} -outdir {out_dir}/2005080800"), ] - all_cmds = wrapper.run_all_times() print(f"ALL COMMANDS: {all_cmds}") assert len(all_cmds) == len(expected_cmds) @@ -585,7 +586,11 @@ def test_ensemble_stat_single_field(metplus_config, config_overrides, assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py b/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py index ed27e0784f..60703aecb2 100644 --- a/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py +++ b/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py @@ -53,35 +53,36 @@ def set_minimum_config_settings(config): @pytest.mark.parametrize( 'config_overrides, env_var_values', [ + # 0 ({'MODEL': 'my_model'}, {'METPLUS_MODEL': 'model = "my_model";'}), - + # 1 ({'GEN_ENS_PROD_DESC': 'my_desc'}, {'METPLUS_DESC': 'desc = "my_desc";'}), - + # 2 ({'DESC': 'my_desc'}, {'METPLUS_DESC': 'desc = "my_desc";'}), - + # 3 ({'GEN_ENS_PROD_REGRID_TO_GRID': 'FCST', }, {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}'}), - + # 4 ({'GEN_ENS_PROD_REGRID_METHOD': 'NEAREST', }, {'METPLUS_REGRID_DICT': 'regrid = {method = NEAREST;}'}), - + # 5 ({'GEN_ENS_PROD_REGRID_WIDTH': '1', }, {'METPLUS_REGRID_DICT': 'regrid = {width = 1;}'}), - + # 6 ({'GEN_ENS_PROD_REGRID_VLD_THRESH': '0.5', }, {'METPLUS_REGRID_DICT': 'regrid = {vld_thresh = 0.5;}'}), - + # 7 ({'GEN_ENS_PROD_REGRID_SHAPE': 'SQUARE', }, {'METPLUS_REGRID_DICT': 'regrid = {shape = SQUARE;}'}), - + # 8 ({'GEN_ENS_PROD_REGRID_TO_GRID': 'FCST', 'GEN_ENS_PROD_REGRID_METHOD': 'NEAREST', 'GEN_ENS_PROD_REGRID_WIDTH': '1', @@ -91,110 +92,65 @@ def set_minimum_config_settings(config): {'METPLUS_REGRID_DICT': ('regrid = {to_grid = FCST;method = NEAREST;' 'width = 1;vld_thresh = 0.5;shape = SQUARE;}' )}), - + # 9 ({'GEN_ENS_PROD_CLIMO_MEAN_INPUT_TEMPLATE': '/some/path/climo/filename.nc', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {file_name = ["/some/path/climo/filename.nc"];}', - 'CLIMO_MEAN_FILE': - '"/some/path/climo/filename.nc"', }), + # 10 ({'GEN_ENS_PROD_CLIMO_STDEV_INPUT_TEMPLATE': '/some/path/climo/stdfile.nc', }, {'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {file_name = ["/some/path/climo/stdfile.nc"];}', - 'CLIMO_STDEV_FILE': - '"/some/path/climo/stdfile.nc"', }), - # 12 mask grid and poly (old config var) - ({'GEN_ENS_PROD_MASK_GRID': 'FULL', - 'GEN_ENS_PROD_VERIFICATION_MASK_TEMPLATE': 'one, two', - }, - {'METPLUS_MASK_GRID': - 'grid = ["FULL"];', - 'METPLUS_MASK_POLY': - 'poly = ["one","two"];', - }), - # 13 mask grid and poly (new config var) - ({'GEN_ENS_PROD_MASK_GRID': 'FULL', - 'GEN_ENS_PROD_MASK_POLY': 'one, two', - }, - {'METPLUS_MASK_GRID': - 'grid = ["FULL"];', - 'METPLUS_MASK_POLY': - 'poly = ["one","two"];', - }), - # 14 mask grid value - ({'GEN_ENS_PROD_MASK_GRID': 'FULL', - }, - {'METPLUS_MASK_GRID': - 'grid = ["FULL"];', - }), - # 15 mask grid empty string (should create empty list) - ({'GEN_ENS_PROD_MASK_GRID': '', - }, - {'METPLUS_MASK_GRID': - 'grid = [];', - }), - # 16 mask poly (old config var) - ({'GEN_ENS_PROD_VERIFICATION_MASK_TEMPLATE': 'one, two', - }, - {'METPLUS_MASK_POLY': - 'poly = ["one","two"];', - }), - # 27 mask poly (new config var) - ({'GEN_ENS_PROD_MASK_POLY': 'one, two', - }, - {'METPLUS_MASK_POLY': - 'poly = ["one","two"];', - }), - # ensemble_flag + # 11 ensemble_flag ({'GEN_ENS_PROD_ENSEMBLE_FLAG_LATLON': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {latlon = FALSE;}'}), - + # 12 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_MEAN': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {mean = FALSE;}'}), - + # 13 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_STDEV': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {stdev = FALSE;}'}), - + # 14 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_MINUS': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {minus = FALSE;}'}), - + # 15 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_PLUS': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {plus = FALSE;}'}), - + # 16 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_MIN': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {min = FALSE;}'}), - + # 17 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_MAX': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {max = FALSE;}'}), - + # 18 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_RANGE': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {range = FALSE;}'}), - + # 19 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_VLD_COUNT': 'FALSE', }, { 'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {vld_count = FALSE;}'}), - + # 20 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_FREQUENCY': 'FALSE', }, { 'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {frequency = FALSE;}'}), - + # 21 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_NEP': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {nep = FALSE;}'}), - + # 22 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_NMEP': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {nmep = FALSE;}'}), - + # 23 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_RANK': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {rank = FALSE;}'}), - + # 24 ({'GEN_ENS_PROD_ENSEMBLE_FLAG_WEIGHT': 'FALSE', }, {'METPLUS_ENSEMBLE_FLAG_DICT': 'ensemble_flag = {weight = FALSE;}'}), - + # 25 ({ 'GEN_ENS_PROD_ENSEMBLE_FLAG_LATLON': 'FALSE', 'GEN_ENS_PROD_ENSEMBLE_FLAG_MEAN': 'FALSE', @@ -220,41 +176,40 @@ def set_minimum_config_settings(config): 'frequency = FALSE;nep = FALSE;' 'nmep = FALSE;rank = FALSE;' 'weight = FALSE;}')}), - + # 26 ({'GEN_ENS_PROD_CLIMO_MEAN_FILE_NAME': '/some/climo_mean/file.txt', }, {'METPLUS_CLIMO_MEAN_DICT': ('climo_mean = {file_name = ' - '["/some/climo_mean/file.txt"];}'), - 'CLIMO_MEAN_FILE': '"/some/climo_mean/file.txt"'}), - + '["/some/climo_mean/file.txt"];}'),}), + # 27 ({'GEN_ENS_PROD_CLIMO_MEAN_FIELD': '{name="CLM_NAME"; level="(0,0,*,*)";}', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {field = [{name="CLM_NAME"; level="(0,0,*,*)";}];}'}), - + # 28 ({'GEN_ENS_PROD_CLIMO_MEAN_REGRID_METHOD': 'NEAREST', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {regrid = {method = NEAREST;}}'}), - + # 29 ({'GEN_ENS_PROD_CLIMO_MEAN_REGRID_WIDTH': '1', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {regrid = {width = 1;}}'}), - + # 30 ({'GEN_ENS_PROD_CLIMO_MEAN_REGRID_VLD_THRESH': '0.5', }, { 'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {regrid = {vld_thresh = 0.5;}}'}), - + # 31 ({'GEN_ENS_PROD_CLIMO_MEAN_REGRID_SHAPE': 'SQUARE', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {regrid = {shape = SQUARE;}}'}), - + # 32 ({'GEN_ENS_PROD_CLIMO_MEAN_TIME_INTERP_METHOD': 'NEAREST', }, { 'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {time_interp_method = NEAREST;}'}), - + # 33 ({'GEN_ENS_PROD_CLIMO_MEAN_MATCH_MONTH': 'True', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {match_month = TRUE;}'}), - + # 34 ({'GEN_ENS_PROD_CLIMO_MEAN_DAY_INTERVAL': '30', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {day_interval = 30;}'}), - + # 35 ({'GEN_ENS_PROD_CLIMO_MEAN_HOUR_INTERVAL': '12', }, {'METPLUS_CLIMO_MEAN_DICT': 'climo_mean = {hour_interval = 12;}'}), - + # 36 ({ 'GEN_ENS_PROD_CLIMO_MEAN_FILE_NAME': '/some/climo_mean/file.txt', 'GEN_ENS_PROD_CLIMO_MEAN_FIELD': '{name="CLM_NAME"; level="(0,0,*,*)";}', @@ -274,46 +229,43 @@ def set_minimum_config_settings(config): 'vld_thresh = 0.5;shape = SQUARE;}' 'time_interp_method = NEAREST;' 'match_month = TRUE;day_interval = 30;' - 'hour_interval = 12;}'), - 'CLIMO_MEAN_FILE': '"/some/climo_mean/file.txt"'}), - - # climo stdev + 'hour_interval = 12;}')}), + # 37 climo stdev ({'GEN_ENS_PROD_CLIMO_STDEV_FILE_NAME': '/some/climo_stdev/file.txt', }, {'METPLUS_CLIMO_STDEV_DICT': ('climo_stdev = {file_name = ' - '["/some/climo_stdev/file.txt"];}'), - 'CLIMO_STDEV_FILE': '"/some/climo_stdev/file.txt"'}), - + '["/some/climo_stdev/file.txt"];}')}), + # 38 ({'GEN_ENS_PROD_CLIMO_STDEV_FIELD': '{name="CLM_NAME"; level="(0,0,*,*)";}', }, {'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {field = [{name="CLM_NAME"; level="(0,0,*,*)";}];}'}), - + # 39 ({'GEN_ENS_PROD_CLIMO_STDEV_REGRID_METHOD': 'NEAREST', }, { 'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {regrid = {method = NEAREST;}}'}), - + # 40 ({'GEN_ENS_PROD_CLIMO_STDEV_REGRID_WIDTH': '1', }, {'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {regrid = {width = 1;}}'}), - + # 41 ({'GEN_ENS_PROD_CLIMO_STDEV_REGRID_VLD_THRESH': '0.5', }, { 'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {regrid = {vld_thresh = 0.5;}}'}), - + # 42 ({'GEN_ENS_PROD_CLIMO_STDEV_REGRID_SHAPE': 'SQUARE', }, { 'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {regrid = {shape = SQUARE;}}'}), - + # 43 ({'GEN_ENS_PROD_CLIMO_STDEV_TIME_INTERP_METHOD': 'NEAREST', }, { 'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {time_interp_method = NEAREST;}'}), - + # 44 ({'GEN_ENS_PROD_CLIMO_STDEV_MATCH_MONTH': 'True', }, {'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {match_month = TRUE;}'}), - + # 45 ({'GEN_ENS_PROD_CLIMO_STDEV_DAY_INTERVAL': '30', }, {'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {day_interval = 30;}'}), - + # 46 ({'GEN_ENS_PROD_CLIMO_STDEV_HOUR_INTERVAL': '12', }, {'METPLUS_CLIMO_STDEV_DICT': 'climo_stdev = {hour_interval = 12;}'}), - + # 47 ({ 'GEN_ENS_PROD_CLIMO_STDEV_FILE_NAME': '/some/climo_stdev/file.txt', 'GEN_ENS_PROD_CLIMO_STDEV_FIELD': '{name="CLM_NAME"; level="(0,0,*,*)";}', @@ -333,17 +285,17 @@ def set_minimum_config_settings(config): 'vld_thresh = 0.5;shape = SQUARE;}' 'time_interp_method = NEAREST;' 'match_month = TRUE;day_interval = 30;' - 'hour_interval = 12;}'), - 'CLIMO_STDEV_FILE': '"/some/climo_stdev/file.txt"'}), + 'hour_interval = 12;}')}), + # 48 ({'GEN_ENS_PROD_NBRHD_PROB_WIDTH': '5', }, {'METPLUS_NBRHD_PROB_DICT': 'nbrhd_prob = {width = [5];}'}), - + # 49 ({'GEN_ENS_PROD_NBRHD_PROB_SHAPE': 'circle', }, {'METPLUS_NBRHD_PROB_DICT': 'nbrhd_prob = {shape = CIRCLE;}'}), - + # 50 ({'GEN_ENS_PROD_NBRHD_PROB_VLD_THRESH': '0.0', }, {'METPLUS_NBRHD_PROB_DICT': 'nbrhd_prob = {vld_thresh = 0.0;}'}), - + # 51 ({ 'GEN_ENS_PROD_NBRHD_PROB_WIDTH': '5', 'GEN_ENS_PROD_NBRHD_PROB_SHAPE': 'CIRCLE', @@ -355,25 +307,26 @@ def set_minimum_config_settings(config): 'vld_thresh = 0.0;}' ) }), + # 52 ({'GEN_ENS_PROD_NMEP_SMOOTH_VLD_THRESH': '0.0', }, {'METPLUS_NMEP_SMOOTH_DICT': 'nmep_smooth = {vld_thresh = 0.0;}'}), - + # 53 ({'GEN_ENS_PROD_NMEP_SMOOTH_SHAPE': 'circle', }, {'METPLUS_NMEP_SMOOTH_DICT': 'nmep_smooth = {shape = CIRCLE;}'}), - + # 54 ({'GEN_ENS_PROD_NMEP_SMOOTH_GAUSSIAN_DX': '81.27', }, {'METPLUS_NMEP_SMOOTH_DICT': 'nmep_smooth = {gaussian_dx = 81.27;}'}), - + # 55 ({'GEN_ENS_PROD_NMEP_SMOOTH_GAUSSIAN_RADIUS': '120', }, { 'METPLUS_NMEP_SMOOTH_DICT': 'nmep_smooth = {gaussian_radius = 120;}'}), - + # 56 ({'GEN_ENS_PROD_NMEP_SMOOTH_TYPE_METHOD': 'GAUSSIAN', }, {'METPLUS_NMEP_SMOOTH_DICT': 'nmep_smooth = {type = [{method = GAUSSIAN;}];}'}), - + # 57 ({'GEN_ENS_PROD_NMEP_SMOOTH_TYPE_WIDTH': '1', }, {'METPLUS_NMEP_SMOOTH_DICT': 'nmep_smooth = {type = [{width = 1;}];}'}), - + # 58 ({ 'GEN_ENS_PROD_NMEP_SMOOTH_VLD_THRESH': '0.0', 'GEN_ENS_PROD_NMEP_SMOOTH_SHAPE': 'circle', @@ -442,7 +395,11 @@ def test_gen_ens_prod_single_field(metplus_config, config_overrides, assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py b/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py index 3fd1e83f5e..b38bee453a 100644 --- a/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py +++ b/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py @@ -129,7 +129,8 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, ({'GRID_STAT_REGRID_TO_GRID': 'FCST', }, - {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}'}), + {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}', + 'REGRID_TO_GRID': 'FCST'}), ({'GRID_STAT_REGRID_METHOD': 'NEAREST', }, @@ -155,7 +156,8 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, }, {'METPLUS_REGRID_DICT': ('regrid = {to_grid = FCST;method = NEAREST;' 'width = 1;vld_thresh = 0.5;shape = SQUARE;}' - )}), + ), + 'REGRID_TO_GRID': 'FCST'}), ({'GRID_STAT_CLIMO_MEAN_INPUT_TEMPLATE': '/some/path/climo/filename.nc', @@ -605,7 +607,11 @@ def test_grid_stat_single_field(metplus_config, config_overrides, assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py b/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py index 47030515d3..d07460055e 100644 --- a/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py +++ b/internal_tests/pytests/ioda2nc/test_ioda2nc_wrapper.py @@ -221,7 +221,11 @@ def test_ioda2nc_wrapper(metplus_config, config_overrides, assert cmd == expected_cmd # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert match is not None diff --git a/internal_tests/pytests/met_config/test_met_config.py b/internal_tests/pytests/met_config/test_met_config.py new file mode 100644 index 0000000000..1e2e5e5f31 --- /dev/null +++ b/internal_tests/pytests/met_config/test_met_config.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +import pytest + +from metplus.util.met_config import * + +@pytest.mark.parametrize( + 'name, data_type, mp_configs, extra_args', [ + ('beg', 'int', 'BEG', None), + ('end', 'int', ['END'], None), + ] +) +def test_met_config_info(name, data_type, mp_configs, extra_args): + item = METConfig(name=name, data_type=data_type) + + item.metplus_configs = mp_configs + item.extra_args = extra_args + + assert(item.name == name) + assert(item.data_type == data_type) + if isinstance(mp_configs, list): + assert(item.metplus_configs == mp_configs) + else: + assert(item.metplus_configs == [mp_configs]) + + if not extra_args: + assert(item.extra_args == {}) + +@pytest.mark.parametrize( + 'data_type, expected_function', [ + ('int', 'set_met_config_int'), + ('float', 'set_met_config_float'), + ('list', 'set_met_config_list'), + ('string', 'set_met_config_string'), + ('thresh', 'set_met_config_thresh'), + ('bool', 'set_met_config_bool'), + ('bad_name', None), + ] +) +def test_set_met_config_function(data_type, expected_function): + try: + function_found = set_met_config_function(data_type) + function_name = function_found.__name__ if function_found else None + assert(function_name == expected_function) + except ValueError: + assert expected_function is None + + +@pytest.mark.parametrize( + 'input, output', [ + ('', 'NONE'), + ('NONE', 'NONE'), + ('FCST', 'FCST'), + ('OBS', 'OBS'), + ('G002', '"G002"'), + ] +) +def test_format_regrid_to_grid(input, output): + assert format_regrid_to_grid(input) == output diff --git a/internal_tests/pytests/met_dictionary_info/test_met_dictionary_info.py b/internal_tests/pytests/met_dictionary_info/test_met_dictionary_info.py deleted file mode 100644 index 4bf2b7b19c..0000000000 --- a/internal_tests/pytests/met_dictionary_info/test_met_dictionary_info.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 - -import pytest - -from metplus.util import METConfigInfo - -@pytest.mark.parametrize( - 'name, data_type, mp_configs, extra_args', [ - ('beg', 'int', 'BEG', None), - ('end', 'int', ['END'], None), - ] -) -def test_met_config_info(name, data_type, mp_configs, extra_args): - item = METConfigInfo(name=name, - data_type=data_type, - ) - item.metplus_configs = mp_configs - item.extra_args = extra_args - - assert(item.name == name) - assert(item.data_type == data_type) - if isinstance(mp_configs, list): - assert(item.metplus_configs == mp_configs) - else: - assert(item.metplus_configs == [mp_configs]) - - if not extra_args: - assert(item.extra_args == {}) - diff --git a/internal_tests/pytests/met_util/test_met_util.py b/internal_tests/pytests/met_util/test_met_util.py index 0154ff68e6..cb3f04af7d 100644 --- a/internal_tests/pytests/met_util/test_met_util.py +++ b/internal_tests/pytests/met_util/test_met_util.py @@ -10,491 +10,7 @@ from metplus.util import met_util as util from metplus.util import time_util - -@pytest.mark.parametrize( - 'regex,index,id,expected_result', [ - # 0: No ID - (r'^FCST_VAR(\d+)_NAME$', 1, None, - {'1': [None], - '2': [None], - '4': [None]}), - # 1: ID and index 2 - (r'(\w+)_VAR(\d+)_NAME', 2, 1, - {'1': ['FCST'], - '2': ['FCST'], - '4': ['FCST']}), - # 2: index 1, ID 2, multiple identifiers - (r'^FCST_VAR(\d+)_(\w+)$', 1, 2, - {'1': ['NAME', 'LEVELS'], - '2': ['NAME'], - '4': ['NAME']}), - # 3: command that StatAnalysis wrapper uses - (r'MODEL(\d+)$', 1, None, - {'1': [None], - '2': [None],}), - # 4: TCPairs conensus logic - (r'^TC_PAIRS_CONSENSUS(\d+)_(\w+)$', 1, 2, - {'1': ['NAME', 'MEMBERS', 'REQUIRED', 'MIN_REQ'], - '2': ['NAME', 'MEMBERS', 'REQUIRED', 'MIN_REQ']}), - ] -) -def test_find_indices_in_config_section(metplus_config, regex, index, - id, expected_result): - config = metplus_config() - config.set('config', 'FCST_VAR1_NAME', 'name1') - config.set('config', 'FCST_VAR1_LEVELS', 'level1') - config.set('config', 'FCST_VAR2_NAME', 'name2') - config.set('config', 'FCST_VAR4_NAME', 'name4') - config.set('config', 'MODEL1', 'model1') - config.set('config', 'MODEL2', 'model2') - - config.set('config', 'TC_PAIRS_CONSENSUS1_NAME', 'name1') - config.set('config', 'TC_PAIRS_CONSENSUS1_MEMBERS', 'member1') - config.set('config', 'TC_PAIRS_CONSENSUS1_REQUIRED', 'True') - config.set('config', 'TC_PAIRS_CONSENSUS1_MIN_REQ', '1') - config.set('config', 'TC_PAIRS_CONSENSUS2_NAME', 'name2') - config.set('config', 'TC_PAIRS_CONSENSUS2_MEMBERS', 'member2') - config.set('config', 'TC_PAIRS_CONSENSUS2_REQUIRED', 'True') - config.set('config', 'TC_PAIRS_CONSENSUS2_MIN_REQ', '2') - - - indices = util.find_indices_in_config_section(regex, config, - index_index=index, - id_index=id) - - pp = pprint.PrettyPrinter() - print(f'Indices:') - pp.pprint(indices) - - assert indices == expected_result - -@pytest.mark.parametrize( - 'data_type, met_tool, expected_out', [ - ('FCST', None, ['FCST_', - 'BOTH_',]), - ('OBS', None, ['OBS_', - 'BOTH_',]), - ('FCST', 'grid_stat', ['FCST_GRID_STAT_', - 'BOTH_GRID_STAT_', - 'FCST_', - 'BOTH_', - ]), - ('OBS', 'extract_tiles', ['OBS_EXTRACT_TILES_', - 'BOTH_EXTRACT_TILES_', - 'OBS_', - 'BOTH_', - ]), - ('ENS', None, ['ENS_']), - ('DATA', None, ['DATA_']), - ('DATA', 'tc_gen', ['DATA_TC_GEN_', - 'DATA_']), - - ] -) -def test_get_field_search_prefixes(data_type, met_tool, expected_out): - assert(util.get_field_search_prefixes(data_type, - met_tool) == expected_out) - -# search prefixes are valid prefixes to append to field info variables -# config_overrides are a dict of config vars and their values -# search_key is the key of the field config item to check -# expected_value is the variable that search_key is set to -@pytest.mark.parametrize( - 'search_prefixes, config_overrides, expected_value', [ - (['BOTH_', 'FCST_'], - {'FCST_VAR1_': 'fcst_var1'}, - 'fcst_var1' - ), - (['BOTH_', 'FCST_'], {}, None), - - (['BOTH_', 'FCST_'], - {'FCST_VAR1_': 'fcst_var1', - 'BOTH_VAR1_': 'both_var1'}, - 'both_var1' - ), - - (['BOTH_GRID_STAT_', 'FCST_GRID_STAT_'], - {'FCST_GRID_STAT_VAR1_': 'fcst_grid_stat_var1'}, - 'fcst_grid_stat_var1' - ), - (['BOTH_GRID_STAT_', 'FCST_GRID_STAT_'], {}, None), - (['BOTH_GRID_STAT_', 'FCST_GRID_STAT_'], - {'FCST_GRID_STAT_VAR1_': 'fcst_grid_stat_var1', - 'BOTH_GRID_STAT_VAR1_': 'both_grid_stat_var1'}, - 'both_grid_stat_var1' - ), - - (['ENS_'], - {'ENS_VAR1_': 'env_var1'}, - 'env_var1' - ), - (['ENS_'], {}, None), - - ] -) -def test_get_field_config_variables(metplus_config, - search_prefixes, - config_overrides, - expected_value): - config = metplus_config() - index = '1' - field_info_types = ['name', 'levels', 'thresh', 'options', 'output_names'] - for field_info_type in field_info_types: - for key, value in config_overrides.items(): - config.set('config', - f'{key}{field_info_type.upper()}', - value) - - field_configs = util.get_field_config_variables(config, - index, - search_prefixes) - - assert(field_configs.get(field_info_type) == expected_value) - -@pytest.mark.parametrize( - 'config_keys, field_key, expected_value', [ - (['NAME', - ], - 'name', 'NAME' - ), - (['NAME', - 'INPUT_FIELD_NAME', - ], - 'name', 'NAME' - ), - (['INPUT_FIELD_NAME', - ], - 'name', 'INPUT_FIELD_NAME' - ), - ([], 'name', None), - (['LEVELS', - ], - 'levels', 'LEVELS' - ), - (['LEVELS', - 'FIELD_LEVEL', - ], - 'levels', 'LEVELS' - ), - (['FIELD_LEVEL', - ], - 'levels', 'FIELD_LEVEL' - ), - ([], 'levels', None), - (['OUTPUT_NAMES', - ], - 'output_names', 'OUTPUT_NAMES' - ), - (['OUTPUT_NAMES', - 'OUTPUT_FIELD_NAME', - ], - 'output_names', 'OUTPUT_NAMES' - ), - (['OUTPUT_FIELD_NAME', - ], - 'output_names', 'OUTPUT_FIELD_NAME' - ), - ([], 'output_names', None), - ] -) -def test_get_field_config_variables_synonyms(metplus_config, - config_keys, - field_key, - expected_value): - config = metplus_config() - index = '1' - prefix = 'BOTH_REGRID_DATA_PLANE_' - for key in config_keys: - config.set('config', f'{prefix}VAR{index}_{key}', key) - - field_configs = util.get_field_config_variables(config, - index, - [prefix]) - - assert(field_configs.get(field_key) == expected_value) - -# ensure that the field configuration used for -# met_tool_wrapper/EnsembleStat/EnsembleStat.conf -# works as expected -def test_parse_var_list_ensemble(metplus_config): - config = metplus_config() - config.set('config', 'ENS_VAR1_NAME', 'APCP') - config.set('config', 'ENS_VAR1_LEVELS', 'A24') - config.set('config', 'ENS_VAR1_THRESH', '>0.0, >=10.0') - config.set('config', 'ENS_VAR2_NAME', 'REFC') - config.set('config', 'ENS_VAR2_LEVELS', 'L0') - config.set('config', 'ENS_VAR2_THRESH', '>35.0') - config.set('config', 'ENS_VAR2_OPTIONS', 'GRIB1_ptv = 129;') - config.set('config', 'ENS_VAR3_NAME', 'UGRD') - config.set('config', 'ENS_VAR3_LEVELS', 'Z10') - config.set('config', 'ENS_VAR3_THRESH', '>=5.0') - config.set('config', 'ENS_VAR4_NAME', 'VGRD') - config.set('config', 'ENS_VAR4_LEVELS', 'Z10') - config.set('config', 'ENS_VAR4_THRESH', '>=5.0') - config.set('config', 'ENS_VAR5_NAME', 'WIND') - config.set('config', 'ENS_VAR5_LEVELS', 'Z10') - config.set('config', 'ENS_VAR5_THRESH', '>=5.0') - config.set('config', 'FCST_VAR1_NAME', 'APCP') - config.set('config', 'FCST_VAR1_LEVELS', 'A24') - config.set('config', 'FCST_VAR1_THRESH', '>0.01, >=10.0') - config.set('config', 'FCST_VAR1_OPTIONS', ('ens_ssvar_bin_size = 0.1; ' - 'ens_phist_bin_size = 0.05;')) - config.set('config', 'OBS_VAR1_NAME', 'APCP') - config.set('config', 'OBS_VAR1_LEVELS', 'A24') - config.set('config', 'OBS_VAR1_THRESH', '>0.01, >=10.0') - config.set('config', 'OBS_VAR1_OPTIONS', ('ens_ssvar_bin_size = 0.1; ' - 'ens_phist_bin_size = 0.05;')) - time_info = {} - - expected_ens_list = [{'index': '1', - 'ens_name': 'APCP', - 'ens_level': 'A24', - 'ens_thresh': ['>0.0', '>=10.0']}, - {'index': '2', - 'ens_name': 'REFC', - 'ens_level': 'L0', - 'ens_thresh': ['>35.0']}, - {'index': '3', - 'ens_name': 'UGRD', - 'ens_level': 'Z10', - 'ens_thresh': ['>=5.0']}, - {'index': '4', - 'ens_name': 'VGRD', - 'ens_level': 'Z10', - 'ens_thresh': ['>=5.0']}, - {'index': '5', - 'ens_name': 'WIND', - 'ens_level': 'Z10', - 'ens_thresh': ['>=5.0']}, - ] - expected_var_list = [{'index': '1', - 'fcst_name': 'APCP', - 'fcst_level': 'A24', - 'fcst_thresh': ['>0.01', '>=10.0'], - 'fcst_extra': ('ens_ssvar_bin_size = 0.1; ' - 'ens_phist_bin_size = 0.05;'), - 'obs_name': 'APCP', - 'obs_level': 'A24', - 'obs_thresh': ['>0.01', '>=10.0'], - 'obs_extra': ('ens_ssvar_bin_size = 0.1; ' - 'ens_phist_bin_size = 0.05;') - - }, - ] - - ensemble_var_list = util.parse_var_list(config, time_info, - data_type='ENS') - - # parse optional var list for FCST and/or OBS fields - var_list = util.parse_var_list(config, time_info, - met_tool='ensemble_stat') - - pp = pprint.PrettyPrinter() - print(f'ENSEMBLE_VAR_LIST:') - pp.pprint(ensemble_var_list) - print(f'VAR_LIST:') - pp.pprint(var_list) - - assert(len(ensemble_var_list) == len(expected_ens_list)) - for actual_ens, expected_ens in zip(ensemble_var_list, expected_ens_list): - for key, value in expected_ens.items(): - assert(actual_ens.get(key) == value) - - assert(len(var_list) == len(expected_var_list)) - for actual_var, expected_var in zip(var_list, expected_var_list): - for key, value in expected_var.items(): - assert(actual_var.get(key) == value) - -def test_parse_var_list_series_by(metplus_config): - config = metplus_config() - config.set('config', 'BOTH_EXTRACT_TILES_VAR1_NAME', 'RH') - config.set('config', 'BOTH_EXTRACT_TILES_VAR1_LEVELS', 'P850, P700') - config.set('config', 'BOTH_EXTRACT_TILES_VAR1_OUTPUT_NAMES', - 'RH_850mb, RH_700mb') - - config.set('config', 'BOTH_SERIES_ANALYSIS_VAR1_NAME', 'RH_850mb') - config.set('config', 'BOTH_SERIES_ANALYSIS_VAR1_LEVELS', 'P850') - config.set('config', 'BOTH_SERIES_ANALYSIS_VAR2_NAME', 'RH_700mb') - config.set('config', 'BOTH_SERIES_ANALYSIS_VAR2_LEVELS', 'P700') - time_info = {} - - expected_et_list = [{'index': '1', - 'fcst_name': 'RH', - 'fcst_level': 'P850', - 'fcst_output_name': 'RH_850mb', - 'obs_name': 'RH', - 'obs_level': 'P850', - 'obs_output_name': 'RH_850mb', - }, - {'index': '1', - 'fcst_name': 'RH', - 'fcst_level': 'P700', - 'fcst_output_name': 'RH_700mb', - 'obs_name': 'RH', - 'obs_level': 'P700', - 'obs_output_name': 'RH_700mb', - }, - ] - expected_sa_list = [{'index': '1', - 'fcst_name': 'RH_850mb', - 'fcst_level': 'P850', - 'obs_name': 'RH_850mb', - 'obs_level': 'P850', - }, - {'index': '2', - 'fcst_name': 'RH_700mb', - 'fcst_level': 'P700', - 'obs_name': 'RH_700mb', - 'obs_level': 'P700', - }, - ] - - actual_et_list = util.parse_var_list(config, - time_info=time_info, - met_tool='extract_tiles') - - actual_sa_list = util.parse_var_list(config, - met_tool='series_analysis') - - pp = pprint.PrettyPrinter() - print(f'ExtractTiles var list:') - pp.pprint(actual_et_list) - print(f'SeriesAnalysis var list:') - pp.pprint(actual_sa_list) - - assert(len(actual_et_list) == len(expected_et_list)) - for actual_et, expected_et in zip(actual_et_list, expected_et_list): - for key, value in expected_et.items(): - assert(actual_et.get(key) == value) - - assert(len(actual_sa_list) == len(expected_sa_list)) - for actual_sa, expected_sa in zip(actual_sa_list, expected_sa_list): - for key, value in expected_sa.items(): - assert(actual_sa.get(key) == value) - -@pytest.mark.parametrize( - 'input_dict, expected_list', [ - ({'init': datetime.datetime(2019, 2, 1, 6), - 'lead': 7200, }, - [ - {'index': '1', - 'fcst_name': 'FNAME_2019', - 'fcst_level': 'Z06', - 'obs_name': 'ONAME_2019', - 'obs_level': 'L06', - }, - {'index': '1', - 'fcst_name': 'FNAME_2019', - 'fcst_level': 'Z08', - 'obs_name': 'ONAME_2019', - 'obs_level': 'L08', - }, - ]), - ({'init': datetime.datetime(2021, 4, 13, 9), - 'lead': 10800, }, - [ - {'index': '1', - 'fcst_name': 'FNAME_2021', - 'fcst_level': 'Z09', - 'obs_name': 'ONAME_2021', - 'obs_level': 'L09', - }, - {'index': '1', - 'fcst_name': 'FNAME_2021', - 'fcst_level': 'Z12', - 'obs_name': 'ONAME_2021', - 'obs_level': 'L12', - }, - ]), - ] -) -def test_sub_var_list(metplus_config, input_dict, expected_list): - config = metplus_config() - config.set('config', 'FCST_VAR1_NAME', 'FNAME_{init?fmt=%Y}') - config.set('config', 'FCST_VAR1_LEVELS', 'Z{init?fmt=%H}, Z{valid?fmt=%H}') - config.set('config', 'OBS_VAR1_NAME', 'ONAME_{init?fmt=%Y}') - config.set('config', 'OBS_VAR1_LEVELS', 'L{init?fmt=%H}, L{valid?fmt=%H}') - - time_info = time_util.ti_calculate(input_dict) - - actual_temp = util.parse_var_list(config) - - pp = pprint.PrettyPrinter() - print(f'Actual var list (before sub):') - pp.pprint(actual_temp) - - actual_list = util.sub_var_list(actual_temp, time_info) - print(f'Actual var list (after sub):') - pp.pprint(actual_list) - - assert(len(actual_list) == len(expected_list)) - for actual, expected in zip(actual_list, expected_list): - for key, value in expected.items(): - assert(actual.get(key) == value) - -@pytest.mark.parametrize( - 'config_var_name, expected_indices, set_met_tool', [ - ('FCST_GRID_STAT_VAR1_NAME', ['1'], True), - ('FCST_GRID_STAT_VAR2_INPUT_FIELD_NAME', ['2'], True), - ('FCST_GRID_STAT_VAR3_FIELD_NAME', ['3'], True), - ('BOTH_GRID_STAT_VAR4_NAME', ['4'], True), - ('BOTH_GRID_STAT_VAR5_INPUT_FIELD_NAME', ['5'], True), - ('BOTH_GRID_STAT_VAR6_FIELD_NAME', ['6'], True), - ('FCST_VAR7_NAME', ['7'], False), - ('FCST_VAR8_INPUT_FIELD_NAME', ['8'], False), - ('FCST_VAR9_FIELD_NAME', ['9'], False), - ('BOTH_VAR10_NAME', ['10'], False), - ('BOTH_VAR11_INPUT_FIELD_NAME', ['11'], False), - ('BOTH_VAR12_FIELD_NAME', ['12'], False), - ] -) -def test_find_var_indices_fcst(metplus_config, - config_var_name, - expected_indices, - set_met_tool): - config = metplus_config() - data_types = ['FCST'] - config.set('config', config_var_name, "NAME1") - met_tool = 'grid_stat' if set_met_tool else None - var_name_indices = util.find_var_name_indices(config, - data_types=data_types, - met_tool=met_tool) - - assert(len(var_name_indices) == len(expected_indices)) - for actual_index in var_name_indices: - assert(actual_index in expected_indices) - -def test_parse_var_list_priority_fcst(metplus_config): - priority_list = ['FCST_GRID_STAT_VAR1_NAME', - 'FCST_GRID_STAT_VAR1_INPUT_FIELD_NAME', - 'FCST_GRID_STAT_VAR1_FIELD_NAME', - 'BOTH_GRID_STAT_VAR1_NAME', - 'BOTH_GRID_STAT_VAR1_INPUT_FIELD_NAME', - 'BOTH_GRID_STAT_VAR1_FIELD_NAME', - 'FCST_VAR1_NAME', - 'FCST_VAR1_INPUT_FIELD_NAME', - 'FCST_VAR1_FIELD_NAME', - 'BOTH_VAR1_NAME', - 'BOTH_VAR1_INPUT_FIELD_NAME', - 'BOTH_VAR1_FIELD_NAME', - ] - time_info = {} - - # loop through priority list, process, then pop first value off and - # process again until all items have been popped. - # This will check that list is in priority order - while(priority_list): - config = metplus_config() - for key in priority_list: - config.set('config', key, key.lower()) - - var_list = util.parse_var_list(config, time_info=time_info, - data_type='FCST', - met_tool='grid_stat') - - assert(len(var_list) == 1) - assert(var_list[0].get('fcst_name') == priority_list[0].lower()) - priority_list.pop(0) +from metplus.util.config_metplus import parse_var_list @pytest.mark.parametrize( 'before, after', [ @@ -662,244 +178,6 @@ def test_getlist_empty(): test_list = util.getlist(l) assert(test_list == []) -# field info only defined in the FCST_* variables -@pytest.mark.parametrize( - 'data_type, list_created', [ - (None, False), - ('FCST', True), - ('OBS', False), - ] -) -def test_parse_var_list_fcst_only(metplus_config, data_type, list_created): - conf = metplus_config() - conf.set('config', 'FCST_VAR1_NAME', "NAME1") - conf.set('config', 'FCST_VAR1_LEVELS', "LEVELS11, LEVELS12") - conf.set('config', 'FCST_VAR2_NAME', "NAME2") - conf.set('config', 'FCST_VAR2_LEVELS', "LEVELS21, LEVELS22") - - # this should not occur because OBS variables are missing - if util.validate_configuration_variables(conf, force_check=True)[1]: - assert(False) - - var_list = util.parse_var_list(conf, time_info=None, data_type=data_type) - - # list will be created if requesting just OBS, but it should not be created if - # nothing was requested because FCST values are missing - if list_created: - assert(var_list[0]['fcst_name'] == "NAME1" and \ - var_list[1]['fcst_name'] == "NAME1" and \ - var_list[2]['fcst_name'] == "NAME2" and \ - var_list[3]['fcst_name'] == "NAME2" and \ - var_list[0]['fcst_level'] == "LEVELS11" and \ - var_list[1]['fcst_level'] == "LEVELS12" and \ - var_list[2]['fcst_level'] == "LEVELS21" and \ - var_list[3]['fcst_level'] == "LEVELS22") - else: - assert(not var_list) - -# field info only defined in the OBS_* variables -@pytest.mark.parametrize( - 'data_type, list_created', [ - (None, False), - ('OBS', True), - ('FCST', False), - ] -) -def test_parse_var_list_obs(metplus_config, data_type, list_created): - conf = metplus_config() - conf.set('config', 'OBS_VAR1_NAME', "NAME1") - conf.set('config', 'OBS_VAR1_LEVELS', "LEVELS11, LEVELS12") - conf.set('config', 'OBS_VAR2_NAME', "NAME2") - conf.set('config', 'OBS_VAR2_LEVELS', "LEVELS21, LEVELS22") - - # this should not occur because FCST variables are missing - if util.validate_configuration_variables(conf, force_check=True)[1]: - assert(False) - - var_list = util.parse_var_list(conf, time_info=None, data_type=data_type) - - # list will be created if requesting just OBS, but it should not be created if - # nothing was requested because FCST values are missing - if list_created: - assert(var_list[0]['obs_name'] == "NAME1" and \ - var_list[1]['obs_name'] == "NAME1" and \ - var_list[2]['obs_name'] == "NAME2" and \ - var_list[3]['obs_name'] == "NAME2" and \ - var_list[0]['obs_level'] == "LEVELS11" and \ - var_list[1]['obs_level'] == "LEVELS12" and \ - var_list[2]['obs_level'] == "LEVELS21" and \ - var_list[3]['obs_level'] == "LEVELS22") - else: - assert(not var_list) - - -# field info only defined in the BOTH_* variables -@pytest.mark.parametrize( - 'data_type, list_created', [ - (None, 'fcst:obs'), - ('FCST', 'fcst'), - ('OBS', 'obs'), - ] -) -def test_parse_var_list_both(metplus_config, data_type, list_created): - conf = metplus_config() - conf.set('config', 'BOTH_VAR1_NAME', "NAME1") - conf.set('config', 'BOTH_VAR1_LEVELS', "LEVELS11, LEVELS12") - conf.set('config', 'BOTH_VAR2_NAME', "NAME2") - conf.set('config', 'BOTH_VAR2_LEVELS', "LEVELS21, LEVELS22") - - # this should not occur because BOTH variables are used - if not util.validate_configuration_variables(conf, force_check=True)[1]: - assert(False) - - var_list = util.parse_var_list(conf, time_info=None, data_type=data_type) - print(f'var_list:{var_list}') - for list_to_check in list_created.split(':'): - if not var_list[0][f'{list_to_check}_name'] == "NAME1" or \ - not var_list[1][f'{list_to_check}_name'] == "NAME1" or \ - not var_list[2][f'{list_to_check}_name'] == "NAME2" or \ - not var_list[3][f'{list_to_check}_name'] == "NAME2" or \ - not var_list[0][f'{list_to_check}_level'] == "LEVELS11" or \ - not var_list[1][f'{list_to_check}_level'] == "LEVELS12" or \ - not var_list[2][f'{list_to_check}_level'] == "LEVELS21" or \ - not var_list[3][f'{list_to_check}_level'] == "LEVELS22": - assert(False) - -# field info defined in both FCST_* and OBS_* variables -def test_parse_var_list_fcst_and_obs(metplus_config): - conf = metplus_config() - conf.set('config', 'FCST_VAR1_NAME', "FNAME1") - conf.set('config', 'FCST_VAR1_LEVELS', "FLEVELS11, FLEVELS12") - conf.set('config', 'FCST_VAR2_NAME', "FNAME2") - conf.set('config', 'FCST_VAR2_LEVELS', "FLEVELS21, FLEVELS22") - conf.set('config', 'OBS_VAR1_NAME', "ONAME1") - conf.set('config', 'OBS_VAR1_LEVELS', "OLEVELS11, OLEVELS12") - conf.set('config', 'OBS_VAR2_NAME', "ONAME2") - conf.set('config', 'OBS_VAR2_LEVELS', "OLEVELS21, OLEVELS22") - - # this should not occur because FCST and OBS variables are found - if not util.validate_configuration_variables(conf, force_check=True)[1]: - assert(False) - - var_list = util.parse_var_list(conf) - - assert(var_list[0]['fcst_name'] == "FNAME1" and \ - var_list[0]['obs_name'] == "ONAME1" and \ - var_list[1]['fcst_name'] == "FNAME1" and \ - var_list[1]['obs_name'] == "ONAME1" and \ - var_list[2]['fcst_name'] == "FNAME2" and \ - var_list[2]['obs_name'] == "ONAME2" and \ - var_list[3]['fcst_name'] == "FNAME2" and \ - var_list[3]['obs_name'] == "ONAME2" and \ - var_list[0]['fcst_level'] == "FLEVELS11" and \ - var_list[0]['obs_level'] == "OLEVELS11" and \ - var_list[1]['fcst_level'] == "FLEVELS12" and \ - var_list[1]['obs_level'] == "OLEVELS12" and \ - var_list[2]['fcst_level'] == "FLEVELS21" and \ - var_list[2]['obs_level'] == "OLEVELS21" and \ - var_list[3]['fcst_level'] == "FLEVELS22" and \ - var_list[3]['obs_level'] == "OLEVELS22") - -# VAR1 defined by FCST, VAR2 defined by OBS -def test_parse_var_list_fcst_and_obs_alternate(metplus_config): - conf = metplus_config() - conf.set('config', 'FCST_VAR1_NAME', "FNAME1") - conf.set('config', 'FCST_VAR1_LEVELS', "FLEVELS11, FLEVELS12") - conf.set('config', 'OBS_VAR2_NAME', "ONAME2") - conf.set('config', 'OBS_VAR2_LEVELS', "OLEVELS21, OLEVELS22") - - # configuration is invalid and parse var list should not give any results - assert(not util.validate_configuration_variables(conf, force_check=True)[1] and not util.parse_var_list(conf)) - -# VAR1 defined by OBS, VAR2 by FCST, VAR3 by both FCST AND OBS -@pytest.mark.parametrize( - 'data_type, list_len, name_levels', [ - (None, 0, None), - ('FCST', 4, ('FNAME2:FLEVELS21','FNAME2:FLEVELS22','FNAME3:FLEVELS31','FNAME3:FLEVELS32')), - ('OBS', 4, ('ONAME1:OLEVELS11','ONAME1:OLEVELS12','ONAME3:OLEVELS31','ONAME3:OLEVELS32')), - ] -) -def test_parse_var_list_fcst_and_obs_and_both(metplus_config, data_type, list_len, name_levels): - conf = metplus_config() - conf.set('config', 'OBS_VAR1_NAME', "ONAME1") - conf.set('config', 'OBS_VAR1_LEVELS', "OLEVELS11, OLEVELS12") - conf.set('config', 'FCST_VAR2_NAME', "FNAME2") - conf.set('config', 'FCST_VAR2_LEVELS', "FLEVELS21, FLEVELS22") - conf.set('config', 'FCST_VAR3_NAME', "FNAME3") - conf.set('config', 'FCST_VAR3_LEVELS', "FLEVELS31, FLEVELS32") - conf.set('config', 'OBS_VAR3_NAME', "ONAME3") - conf.set('config', 'OBS_VAR3_LEVELS', "OLEVELS31, OLEVELS32") - - # configuration is invalid and parse var list should not give any results - if util.validate_configuration_variables(conf, force_check=True)[1]: - assert(False) - - var_list = util.parse_var_list(conf, time_info=None, data_type=data_type) - - if len(var_list) != list_len: - assert(False) - - if data_type is None: - assert(len(var_list) == 0) - - if name_levels is not None: - dt_lower = data_type.lower() - expected = [] - for name_level in name_levels: - name, level = name_level.split(':') - expected.append({f'{dt_lower}_name': name, - f'{dt_lower}_level': level}) - - for expect, reality in zip(expected,var_list): - if expect[f'{dt_lower}_name'] != reality[f'{dt_lower}_name']: - assert(False) - - if expect[f'{dt_lower}_level'] != reality[f'{dt_lower}_level']: - assert(False) - - assert(True) - -# option defined in obs only -@pytest.mark.parametrize( - 'data_type, list_len', [ - (None, 0), - ('FCST', 2), - ('OBS', 0), - ] -) -def test_parse_var_list_fcst_only_options(metplus_config, data_type, list_len): - conf = metplus_config() - conf.set('config', 'FCST_VAR1_NAME', "NAME1") - conf.set('config', 'FCST_VAR1_LEVELS', "LEVELS11, LEVELS12") - conf.set('config', 'FCST_VAR1_THRESH', ">1, >2") - conf.set('config', 'OBS_VAR1_OPTIONS', "OOPTIONS11") - - # this should not occur because OBS variables are missing - if util.validate_configuration_variables(conf, force_check=True)[1]: - assert(False) - - var_list = util.parse_var_list(conf, time_info=None, data_type=data_type) - - assert(len(var_list) == list_len) - -@pytest.mark.parametrize( - 'met_tool, indices', [ - (None, {'1': ['FCST']}), - ('GRID_STAT', {'2': ['FCST']}), - ('ENSEMBLE_STAT', {}), - ] -) -def test_find_var_indices_wrapper_specific(metplus_config, met_tool, indices): - conf = metplus_config() - data_type = 'FCST' - conf.set('config', f'{data_type}_VAR1_NAME', "NAME1") - conf.set('config', f'{data_type}_GRID_STAT_VAR2_NAME', "GSNAME2") - - var_name_indices = util.find_var_name_indices(conf, data_types=[data_type], - met_tool=met_tool) - - assert(var_name_indices == indices) - def test_get_lead_sequence_lead(metplus_config): input_dict = {'valid': datetime.datetime(2019, 2, 1, 13)} conf = metplus_config() @@ -1089,201 +367,6 @@ def test_get_lead_sequence_init_min_10(metplus_config): lead_seq = [12, 24] assert(test_seq == [relativedelta(hours=lead) for lead in lead_seq]) -@pytest.mark.parametrize( - 'item_list, extension, is_valid', [ - (['FCST'], 'NAME', False), - (['OBS'], 'NAME', False), - (['FCST', 'OBS'], 'NAME', True), - (['BOTH'], 'NAME', True), - (['FCST', 'OBS', 'BOTH'], 'NAME', False), - (['FCST', 'ENS'], 'NAME', False), - (['OBS', 'ENS'], 'NAME', False), - (['FCST', 'OBS', 'ENS'], 'NAME', True), - (['BOTH', 'ENS'], 'NAME', True), - (['FCST', 'OBS', 'BOTH', 'ENS'], 'NAME', False), - - (['FCST', 'OBS'], 'THRESH', True), - (['BOTH'], 'THRESH', True), - (['FCST', 'OBS', 'BOTH'], 'THRESH', False), - (['FCST', 'OBS', 'ENS'], 'THRESH', True), - (['BOTH', 'ENS'], 'THRESH', True), - (['FCST', 'OBS', 'BOTH', 'ENS'], 'THRESH', False), - - (['FCST'], 'OPTIONS', True), - (['OBS'], 'OPTIONS', True), - (['FCST', 'OBS'], 'OPTIONS', True), - (['BOTH'], 'OPTIONS', True), - (['FCST', 'OBS', 'BOTH'], 'OPTIONS', False), - (['FCST', 'ENS'], 'OPTIONS', True), - (['OBS', 'ENS'], 'OPTIONS', True), - (['FCST', 'OBS', 'ENS'], 'OPTIONS', True), - (['BOTH', 'ENS'], 'OPTIONS', True), - (['FCST', 'OBS', 'BOTH', 'ENS'], 'OPTIONS', False), - - (['FCST', 'OBS', 'BOTH'], 'LEVELS', False), - (['FCST', 'OBS'], 'LEVELS', True), - (['BOTH'], 'LEVELS', True), - (['FCST', 'OBS', 'ENS'], 'LEVELS', True), - (['BOTH', 'ENS'], 'LEVELS', True), - - ] -) -def test_is_var_item_valid(metplus_config, item_list, extension, is_valid): - conf = metplus_config() - assert(util.is_var_item_valid(item_list, '1', extension, conf)[0] == is_valid) - -@pytest.mark.parametrize( - 'item_list, configs_to_set, is_valid', [ - - (['FCST'], {'FCST_VAR1_LEVELS': 'A06', - 'OBS_VAR1_NAME': 'script_name.py something else'}, True), - (['FCST'], {'FCST_VAR1_LEVELS': 'A06', - 'OBS_VAR1_NAME': 'APCP'}, False), - (['OBS'], {'OBS_VAR1_LEVELS': '"(*,*)"', - 'FCST_VAR1_NAME': 'script_name.py something else'}, True), - (['OBS'], {'OBS_VAR1_LEVELS': '"(*,*)"', - 'FCST_VAR1_NAME': 'APCP'}, False), - - (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06', - 'OBS_VAR1_NAME': 'script_name.py something else'}, True), - (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06', - 'OBS_VAR1_NAME': 'APCP'}, False), - (['OBS', 'ENS'], {'OBS_VAR1_LEVELS': '"(*,*)"', - 'FCST_VAR1_NAME': 'script_name.py something else'}, True), - (['OBS', 'ENS'], {'OBS_VAR1_LEVELS': '"(*,*)"', - 'FCST_VAR1_NAME': 'APCP'}, False), - - (['FCST'], {'FCST_VAR1_LEVELS': 'A06, A12', - 'OBS_VAR1_NAME': 'script_name.py something else'}, False), - (['FCST'], {'FCST_VAR1_LEVELS': 'A06, A12', - 'OBS_VAR1_NAME': 'APCP'}, False), - (['OBS'], {'OBS_VAR1_LEVELS': '"(0,*,*)", "(1,*,*)"', - 'FCST_VAR1_NAME': 'script_name.py something else'}, False), - - (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06, A12', - 'OBS_VAR1_NAME': 'script_name.py something else'}, False), - (['FCST', 'ENS'], {'FCST_VAR1_LEVELS': 'A06, A12', - 'OBS_VAR1_NAME': 'APCP'}, False), - (['OBS', 'ENS'], {'OBS_VAR1_LEVELS': '"(0,*,*)", "(1,*,*)"', - 'FCST_VAR1_NAME': 'script_name.py something else'}, False), - - ] -) -def test_is_var_item_valid_levels(metplus_config, item_list, configs_to_set, is_valid): - conf = metplus_config() - for key, value in configs_to_set.items(): - conf.set('config', key, value) - - assert(util.is_var_item_valid(item_list, '1', 'LEVELS', conf)[0] == is_valid) - -# test that if wrapper specific field info is specified, it only gets -# values from that list. All generic values should be read if no -# wrapper specific field info variables are specified -def test_parse_var_list_wrapper_specific(metplus_config): - conf = metplus_config() - conf.set('config', 'FCST_VAR1_NAME', "ENAME1") - conf.set('config', 'FCST_VAR1_LEVELS', "ELEVELS11, ELEVELS12") - conf.set('config', 'FCST_VAR2_NAME', "ENAME2") - conf.set('config', 'FCST_VAR2_LEVELS', "ELEVELS21, ELEVELS22") - conf.set('config', 'FCST_GRID_STAT_VAR1_NAME', "GNAME1") - conf.set('config', 'FCST_GRID_STAT_VAR1_LEVELS', "GLEVELS11, GLEVELS12") - - e_var_list = util.parse_var_list(conf, - time_info=None, - data_type='FCST', - met_tool='ensemble_stat') - - g_var_list = util.parse_var_list(conf, - time_info=None, - data_type='FCST', - met_tool='grid_stat') - - assert(len(e_var_list) == 4 and len(g_var_list) == 2 and - e_var_list[0]['fcst_name'] == "ENAME1" and - e_var_list[1]['fcst_name'] == "ENAME1" and - e_var_list[2]['fcst_name'] == "ENAME2" and - e_var_list[3]['fcst_name'] == "ENAME2" and - e_var_list[0]['fcst_level'] == "ELEVELS11" and - e_var_list[1]['fcst_level'] == "ELEVELS12" and - e_var_list[2]['fcst_level'] == "ELEVELS21" and - e_var_list[3]['fcst_level'] == "ELEVELS22" and - g_var_list[0]['fcst_name'] == "GNAME1" and - g_var_list[1]['fcst_name'] == "GNAME1" and - g_var_list[0]['fcst_level'] == "GLEVELS11" and - g_var_list[1]['fcst_level'] == "GLEVELS12") - -@pytest.mark.parametrize( - 'input_list, expected_list', [ - ('Point2Grid', ['Point2Grid']), - # MET documentation syntax (with dashes) - ('Pcp-Combine, Grid-Stat, Ensemble-Stat', ['PCPCombine', - 'GridStat', - 'EnsembleStat']), - ('Point-Stat', ['PointStat']), - ('Mode, MODE Time Domain', ['MODE', - 'MTD']), - # actual tool name (lower case underscore) - ('point_stat, grid_stat, ensemble_stat', ['PointStat', - 'GridStat', - 'EnsembleStat']), - ('mode, mtd', ['MODE', - 'MTD']), - ('ascii2nc, pb2nc, regrid_data_plane', ['ASCII2NC', - 'PB2NC', - 'RegridDataPlane']), - ('pcp_combine, tc_pairs, tc_stat', ['PCPCombine', - 'TCPairs', - 'TCStat']), - ('gen_vx_mask, stat_analysis, series_analysis', ['GenVxMask', - 'StatAnalysis', - 'SeriesAnalysis']), - # old capitalization format - ('PcpCombine, Ascii2Nc, TcStat, TcPairs', ['PCPCombine', - 'ASCII2NC', - 'TCStat', - 'TCPairs']), - # remove MakePlots from list - ('StatAnalysis, MakePlots', ['StatAnalysis']), - ] -) -def test_get_process_list(metplus_config, input_list, expected_list): - conf = metplus_config() - conf.set('config', 'PROCESS_LIST', input_list) - process_list = util.get_process_list(conf) - output_list = [item[0] for item in process_list] - assert(output_list == expected_list) - -@pytest.mark.parametrize( - 'input_list, expected_list', [ - # no instances - ('Point2Grid', [('Point2Grid', None)]), - # one with instance one without - ('PcpCombine, GridStat(my_instance)', [('PCPCombine', None), - ('GridStat', 'my_instance')]), - # duplicate process, one with instance one without - ('TCStat, ExtractTiles, TCStat(for_series), SeriesAnalysis', ( - [('TCStat',None), - ('ExtractTiles',None), - ('TCStat', 'for_series'), - ('SeriesAnalysis',None),])), - # two processes, both with instances - ('mode(uno), mtd(dos)', [('MODE', 'uno'), - ('MTD', 'dos')]), - # lower-case names, first with instance, second without - ('ascii2nc(some_name), pb2nc', [('ASCII2NC', 'some_name'), - ('PB2NC', None)]), - # duplicate process, both with different instances - ('tc_stat(one), tc_pairs, tc_stat(two)', [('TCStat', 'one'), - ('TCPairs', None), - ('TCStat', 'two')]), - ] -) -def test_get_process_list_instances(metplus_config, input_list, expected_list): - conf = metplus_config() - conf.set('config', 'PROCESS_LIST', input_list) - output_list = util.get_process_list(conf) - assert(output_list == expected_list) - @pytest.mark.parametrize( 'time_from_conf, fmt, is_datetime', [ ('', '%Y', False), @@ -1363,23 +446,6 @@ def test_fix_list(list_str, expected_fixed_list): def test_camel_to_underscore(camel, underscore): assert(util.camel_to_underscore(camel) == underscore) -@pytest.mark.parametrize( - 'filepath, template, expected_result', [ - (os.getcwd(), 'file.{valid?fmt=%Y%m%d%H}.ext', None), - ('file.2019020104.ext', 'file.{valid?fmt=%Y%m%d%H}.ext', datetime.datetime(2019, 2, 1, 4)), - ('filename.2019020104.ext', 'file.{valid?fmt=%Y%m%d%H}.ext', None), - ('file.2019020104.ext.gz', 'file.{valid?fmt=%Y%m%d%H}.ext', datetime.datetime(2019, 2, 1, 4)), - ('filename.2019020104.ext.gz', 'file.{valid?fmt=%Y%m%d%H}.ext', None), - ] -) -def test_get_time_from_file(filepath, template, expected_result): - result = util.get_time_from_file(filepath, template) - - if result is None: - assert(expected_result is None) - else: - assert(result['valid'] == expected_result) - @pytest.mark.parametrize( 'value, expected_result', [ (3.3, 3.5), @@ -1404,29 +470,6 @@ def test_round_0p5(value, expected_result): def test_comparison_to_letter_format(expression, expected_result): assert(util.comparison_to_letter_format(expression) == expected_result) -@pytest.mark.parametrize( - 'conf_items, met_tool, expected_result', [ - ({'CUSTOM_LOOP_LIST': "one, two, three"}, '', ['one', 'two', 'three']), - ({'CUSTOM_LOOP_LIST': "one, two, three", - 'GRID_STAT_CUSTOM_LOOP_LIST': "four, five",}, 'grid_stat', ['four', 'five']), - ({'CUSTOM_LOOP_LIST': "one, two, three", - 'GRID_STAT_CUSTOM_LOOP_LIST': "four, five",}, 'point_stat', ['one', 'two', 'three']), - ({'CUSTOM_LOOP_LIST': "one, two, three", - 'ASCII2NC_CUSTOM_LOOP_LIST': "four, five",}, 'ascii2nc', ['four', 'five']), - # fails to read custom loop list for point2grid because there are underscores in name - ({'CUSTOM_LOOP_LIST': "one, two, three", - 'POINT_2_GRID_CUSTOM_LOOP_LIST': "four, five",}, 'point2grid', ['one', 'two', 'three']), - ({'CUSTOM_LOOP_LIST': "one, two, three", - 'POINT2GRID_CUSTOM_LOOP_LIST': "four, five",}, 'point2grid', ['four', 'five']), - ] -) -def test_get_custom_string_list(metplus_config, conf_items, met_tool, expected_result): - config = metplus_config() - for conf_key, conf_value in conf_items.items(): - config.set('config', conf_key, conf_value) - - assert(util.get_custom_string_list(config, met_tool) == expected_result) - @pytest.mark.parametrize( 'skip_times_conf, expected_dict', [ ('"%d:30,31"', {'%d': ['30','31']}), @@ -1574,7 +617,7 @@ def test_get_storm_ids(metplus_config, filename, expected_result): 'stat_data', filename) - assert(util.get_storm_ids(filepath) == expected_result) + assert(util.get_storms(filepath, id_only=True) == expected_result) @pytest.mark.parametrize( 'filename, expected_result', [ @@ -1669,80 +712,6 @@ def test_format_var_items_options_semicolon(config_value, result = var_items.get('extra') assert(result == expected_result) -@pytest.mark.parametrize( - 'config_overrides, expected_results', [ - # 2 levels - ({'FCST_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {fcst_level}', - 'FCST_VAR1_LEVELS': 'P500,P250', - 'OBS_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {obs_level}', - 'OBS_VAR1_LEVELS': 'P500,P250', - }, - ['read_data.py TMP 20200201 P500', - 'read_data.py TMP 20200201 P250', - ]), - ({'BOTH_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {fcst_level}', - 'BOTH_VAR1_LEVELS': 'P500,P250', - }, - ['read_data.py TMP 20200201 P500', - 'read_data.py TMP 20200201 P250', - ]), - # no level but level specified in name - ({'FCST_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {fcst_level}', - 'OBS_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d} {obs_level}', - }, - ['read_data.py TMP 20200201 ', - ]), - # no level - ({'FCST_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d}', - 'OBS_VAR1_NAME': 'read_data.py TMP {valid?fmt=%Y%m%d}', - }, - ['read_data.py TMP 20200201', - ]), - # real example - ({'BOTH_VAR1_NAME': ('myscripts/read_nc2xr.py ' - 'mydata/forecast_file.nc4 TMP ' - '{valid?fmt=%Y%m%d_%H%M} {fcst_level}'), - 'BOTH_VAR1_LEVELS': 'P1000,P850,P700,P500,P250,P100', - }, - [('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' - ' P1000'), - ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' - ' P850'), - ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' - ' P700'), - ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' - ' P500'), - ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' - ' P250'), - ('myscripts/read_nc2xr.py mydata/forecast_file.nc4 TMP 20200201_1225' - ' P100'), - ]), - ] -) -def test_parse_var_list_py_embed_multi_levels(metplus_config, config_overrides, - expected_results): - config = metplus_config() - for key, value in config_overrides.items(): - config.set('config', key, value) - - time_info = {'valid': datetime.datetime(2020, 2, 1, 12, 25)} - var_list = util.parse_var_list(config, - time_info=time_info, - data_type=None) - assert(len(var_list) == len(expected_results)) - - for var_item, expected_result in zip(var_list, expected_results): - assert(var_item['fcst_name'] == expected_result) - - # run again with data type specified - var_list = util.parse_var_list(config, - time_info=time_info, - data_type='FCST') - assert(len(var_list) == len(expected_results)) - - for var_item, expected_result in zip(var_list, expected_results): - assert(var_item['fcst_name'] == expected_result) - @pytest.mark.parametrize( 'level, expected_result', [ ('level', 'level'), @@ -1753,3 +722,63 @@ def test_parse_var_list_py_embed_multi_levels(metplus_config, config_overrides, ) def test_format_level(level, expected_result): assert(util.format_level(level) == expected_result) + +@pytest.mark.parametrize( + 'input_dict, expected_list', [ + ({'init': datetime.datetime(2019, 2, 1, 6), + 'lead': 7200, }, + [ + {'index': '1', + 'fcst_name': 'FNAME_2019', + 'fcst_level': 'Z06', + 'obs_name': 'ONAME_2019', + 'obs_level': 'L06', + }, + {'index': '1', + 'fcst_name': 'FNAME_2019', + 'fcst_level': 'Z08', + 'obs_name': 'ONAME_2019', + 'obs_level': 'L08', + }, + ]), + ({'init': datetime.datetime(2021, 4, 13, 9), + 'lead': 10800, }, + [ + {'index': '1', + 'fcst_name': 'FNAME_2021', + 'fcst_level': 'Z09', + 'obs_name': 'ONAME_2021', + 'obs_level': 'L09', + }, + {'index': '1', + 'fcst_name': 'FNAME_2021', + 'fcst_level': 'Z12', + 'obs_name': 'ONAME_2021', + 'obs_level': 'L12', + }, + ]), + ] +) +def test_sub_var_list(metplus_config, input_dict, expected_list): + config = metplus_config() + config.set('config', 'FCST_VAR1_NAME', 'FNAME_{init?fmt=%Y}') + config.set('config', 'FCST_VAR1_LEVELS', 'Z{init?fmt=%H}, Z{valid?fmt=%H}') + config.set('config', 'OBS_VAR1_NAME', 'ONAME_{init?fmt=%Y}') + config.set('config', 'OBS_VAR1_LEVELS', 'L{init?fmt=%H}, L{valid?fmt=%H}') + + time_info = time_util.ti_calculate(input_dict) + + actual_temp = parse_var_list(config) + + pp = pprint.PrettyPrinter() + print(f'Actual var list (before sub):') + pp.pprint(actual_temp) + + actual_list = util.sub_var_list(actual_temp, time_info) + print(f'Actual var list (after sub):') + pp.pprint(actual_list) + + assert(len(actual_list) == len(expected_list)) + for actual, expected in zip(actual_list, expected_list): + for key, value in expected.items(): + assert(actual.get(key) == value) diff --git a/internal_tests/pytests/mode/test_mode_wrapper.py b/internal_tests/pytests/mode/test_mode_wrapper.py index 69a17f9146..8c4144fd68 100644 --- a/internal_tests/pytests/mode/test_mode_wrapper.py +++ b/internal_tests/pytests/mode/test_mode_wrapper.py @@ -74,7 +74,8 @@ def set_minimum_config_settings(config): ({'MODE_REGRID_TO_GRID': 'FCST', }, - {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}'}), + {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}', + 'REGRID_TO_GRID': 'FCST'}), ({'MODE_REGRID_METHOD': 'NEAREST', }, @@ -100,7 +101,8 @@ def set_minimum_config_settings(config): }, {'METPLUS_REGRID_DICT': ('regrid = {to_grid = FCST;method = NEAREST;' 'width = 1;vld_thresh = 0.5;shape = SQUARE;}' - )}), + ), + 'REGRID_TO_GRID': 'FCST'}), ({'MODE_QUILT': 'True'}, {'METPLUS_QUILT': 'quilt = TRUE;'}), @@ -362,7 +364,11 @@ def test_mode_single_field(metplus_config, config_overrides, assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in expected_output + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/internal_tests/pytests/mtd/test_mtd_wrapper.py b/internal_tests/pytests/mtd/test_mtd_wrapper.py index 27cf14b611..aff12a1bb7 100644 --- a/internal_tests/pytests/mtd/test_mtd_wrapper.py +++ b/internal_tests/pytests/mtd/test_mtd_wrapper.py @@ -1,39 +1,12 @@ #!/usr/bin/env python3 import os -import sys -import re -import logging import datetime -from collections import namedtuple import pytest -import produtil - from metplus.wrappers.mtd_wrapper import MTDWrapper -from metplus.util import met_util as util - -# --------------------TEST CONFIGURATION and FIXTURE SUPPORT ------------- -# -# The test configuration and fixture support the additional configuration -# files used in METplus -# !!!!!!!!!!!!!!! -# !!!IMPORTANT!!! -# !!!!!!!!!!!!!!! -# The following two methods should be included in ALL pytest tests for METplus. -# -# -#def pytest_addoption(parser): -# parser.addoption("-c", action="store", help=" -c ") - - -# @pytest.fixture -#def cmdopt(request): -# return request.config.getoption("-c") -# -----------------FIXTURES THAT CAN BE USED BY ALL TESTS---------------- -#@pytest.fixture def mtd_wrapper(metplus_config, lead_seq=None): """! Returns a default MTDWrapper with /path/to entries in the metplus_system.conf and metplus_runtime.conf configuration diff --git a/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py b/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py index f9c3ab8cc0..a1c202c335 100644 --- a/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py +++ b/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py @@ -283,7 +283,7 @@ def test_find_input_files(metplus_config, offsets, offset_to_find): ] ) def test_pb2nc_all_fields(metplus_config, config_overrides, - env_var_values): + env_var_values): input_dir = '/some/input/dir' config = metplus_config() @@ -341,7 +341,11 @@ def test_pb2nc_all_fields(metplus_config, config_overrides, assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/internal_tests/pytests/point_stat/test_point_stat_wrapper.py b/internal_tests/pytests/point_stat/test_point_stat_wrapper.py index e8c2302f35..f706e702d1 100755 --- a/internal_tests/pytests/point_stat/test_point_stat_wrapper.py +++ b/internal_tests/pytests/point_stat/test_point_stat_wrapper.py @@ -61,12 +61,10 @@ def test_met_dictionary_in_var_options(metplus_config): ({'DESC': 'my_desc'}, {'METPLUS_DESC': 'desc = "my_desc";'}), - ({'OBTYPE': 'my_obtype'}, - {'METPLUS_OBTYPE': 'obtype = "my_obtype";'}), - ({'POINT_STAT_REGRID_TO_GRID': 'FCST', }, - {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}'}), + {'METPLUS_REGRID_DICT': 'regrid = {to_grid = FCST;}', + 'REGRID_TO_GRID': 'FCST'}), ({'POINT_STAT_REGRID_METHOD': 'NEAREST', }, @@ -92,7 +90,8 @@ def test_met_dictionary_in_var_options(metplus_config): }, {'METPLUS_REGRID_DICT': ('regrid = {to_grid = FCST;method = NEAREST;' 'width = 1;vld_thresh = 0.5;shape = SQUARE;}' - )}), + ), + 'REGRID_TO_GRID': 'FCST'}), # mask grid and poly (old config var) ({'POINT_STAT_MASK_GRID': 'FULL', @@ -139,15 +138,6 @@ def test_met_dictionary_in_var_options(metplus_config): 'sid = ["one", "two"];', }), - ({'POINT_STAT_NEIGHBORHOOD_COV_THRESH': '>=0.5'}, - {'METPLUS_NBRHD_COV_THRESH': 'cov_thresh = [>=0.5];'}), - - ({'POINT_STAT_NEIGHBORHOOD_WIDTH': '1,2'}, - {'METPLUS_NBRHD_WIDTH': 'width = [1, 2];'}), - - ({'POINT_STAT_NEIGHBORHOOD_SHAPE': 'CIRCLE'}, - {'METPLUS_NBRHD_SHAPE': 'shape = CIRCLE;'}), - ({'POINT_STAT_OUTPUT_PREFIX': 'my_output_prefix'}, {'METPLUS_OUTPUT_PREFIX': 'output_prefix = "my_output_prefix";'}), @@ -159,6 +149,8 @@ def test_met_dictionary_in_var_options(metplus_config): }, {'METPLUS_OBS_WINDOW_DICT': 'obs_window = {beg = -2700;end = 2700;}', + 'OBS_WINDOW_BEGIN': '-2700', + 'OBS_WINDOW_END': '2700' }), ({'POINT_STAT_CLIMO_CDF_CDF_BINS': '1', }, @@ -507,7 +499,11 @@ def test_point_stat_all_fields(metplus_config, config_overrides, assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/internal_tests/pytests/tc_gen/test_tc_gen_wrapper.py b/internal_tests/pytests/tc_gen/test_tc_gen_wrapper.py index 9486c629b6..825a48d67a 100644 --- a/internal_tests/pytests/tc_gen/test_tc_gen_wrapper.py +++ b/internal_tests/pytests/tc_gen/test_tc_gen_wrapper.py @@ -346,7 +346,11 @@ def test_tc_gen(metplus_config, config_overrides, env_var_values): assert(cmd == expected_cmd) # check that environment variables were set properly - for env_var_key in wrapper.WRAPPER_ENV_VAR_KEYS: + # including deprecated env vars (not in wrapper env var keys) + env_var_keys = (wrapper.WRAPPER_ENV_VAR_KEYS + + [name for name in env_var_values + if name not in wrapper.WRAPPER_ENV_VAR_KEYS]) + for env_var_key in env_var_keys: match = next((item for item in env_vars if item.startswith(env_var_key)), None) assert(match is not None) diff --git a/metplus/util/__init__.py b/metplus/util/__init__.py index 570ee45208..b124dcb5b8 100644 --- a/metplus/util/__init__.py +++ b/metplus/util/__init__.py @@ -1,7 +1,8 @@ +from .constants import * from .metplus_check import * from .doc_util import * from .config_metplus import * from .time_util import * from .met_util import * from .string_template_substitution import * -from .met_dictionary_info import * +from .met_config import * diff --git a/metplus/util/config_metplus.py b/metplus/util/config_metplus.py index 3a6df221d3..892990994e 100644 --- a/metplus/util/config_metplus.py +++ b/metplus/util/config_metplus.py @@ -13,26 +13,23 @@ import re import sys import logging -import collections import datetime import shutil -from os.path import dirname, realpath -import inspect from configparser import ConfigParser, NoOptionError from pathlib import Path -import copy from produtil.config import ProdConfig -import produtil.fileop from . import met_util as util +from .string_template_substitution import get_tags, do_string_sub +from .met_util import getlist, is_python_script, format_var_items +from .doc_util import get_wrapper_name """!Creates the initial METplus directory structure, loads information into each job. This module is used to create the initial METplus conf file in the first METplus job via the metplus.config_metplus.launch(). -The metplus.config_metplus.load() then reloads that configuration. The launch() function does more than just create the conf file though. It creates several initial files and directories @@ -43,15 +40,16 @@ """ '''!@var __all__ -All symbols exported by "from metplus.util.config.config_metplus import *" +All symbols exported by "from metplus.util.config_metplus import *" ''' -__all__ = ['load', - 'launch', - 'parse_launch_args', - 'setup', - 'METplusConfig', - 'METplusLogFormatter', - ] +__all__ = [ + 'setup', + 'get_custom_string_list', + 'find_indices_in_config_section', + 'parse_var_list', + 'get_process_list', + 'validate_configuration_variables', +] '''!@var METPLUS_BASE The METplus installation directory @@ -81,7 +79,33 @@ 'metplus_logging.conf' ] -def get_default_config_list(parm_base=None): +def setup(args, logger=None, base_confs=None): + """!The METplus setup function. + @param args list of configuration files or configuration + variable overrides. Reads all configuration inputs and returns + a configuration object. + """ + if base_confs is None: + base_confs = _get_default_config_list() + + # Setup Task logger, Until a Conf object is created, Task logger is + # only logging to tty, not a file. + if logger is None: + logger = logging.getLogger('metplus') + + logger.info('Starting METplus configuration setup.') + + override_list = _parse_launch_args(args, logger) + + # add default config files to override list + override_list = base_confs + override_list + config = launch(override_list) + + logger.debug('Completed METplus configuration setup.') + + return config + +def _get_default_config_list(parm_base=None): """! Get list of default METplus config files. Look through BASE_CONFS list and check if each file exists under the parm base. Add each to a list if they do exist. @@ -105,38 +129,12 @@ def get_default_config_list(parm_base=None): default_config_list.append(conf_path) if not default_config_list: - print(f"ERROR: No default config files found in {conf_dir}") + print(f"FATAL: No default config files found in {conf_dir}") sys.exit(1) return default_config_list -def setup(args, logger=None, base_confs=None): - """!The METplus setup function. - @param args list of configuration files or configuration - variable overrides. Reads all configuration inputs and returns - a configuration object. - """ - if base_confs is None: - base_confs = get_default_config_list() - - # Setup Task logger, Until a Conf object is created, Task logger is - # only logging to tty, not a file. - if logger is None: - logger = logging.getLogger('metplus') - - logger.info('Starting METplus configuration setup.') - - override_list = parse_launch_args(args, logger) - - # add default config files to override list - override_list = base_confs + override_list - config = launch(override_list) - - logger.debug('Completed METplus configuration setup.') - - return config - -def parse_launch_args(args, logger): +def _parse_launch_args(args, logger): """! Parsed arguments to scripts that launch the METplus wrappers. Options: @@ -207,8 +205,7 @@ def launch(config_list): read files. Explicit configuration variables are read after all config files are processed. - @param file_list list of configuration files to read - @param moreopt explicit configuration variable overrides + @param config_list list of configuration files to process """ config = METplusConfig() logger = config.log() @@ -235,7 +232,7 @@ def launch(config_list): config_format_list.append(f'{section}.{key}={value}') # move all config variables from old sections into the [config] section - config.move_all_to_config_section() + config._move_all_to_config_section() # save list of user configuration files in a variable config.set('config', 'CONFIG_INPUT', ','.join(config_format_list)) @@ -265,19 +262,6 @@ def launch(config_list): return config -def load(filename): - """!Loads the METplusConfig created by the launch() function. - - Creates an METplusConfig object for a METplus workflow that was - previously initialized by metplus.config_metplus.launch. - The only argument is the name of the config file produced by - the launch command. - - @param filename The metplus*.conf file created by launch()""" - config = METplusConfig() - config.read(filename) - return config - def _set_logvars(config, logger=None): """!Sets and adds the LOG_METPLUS and LOG_TIMESTAMP to the config object. If LOG_METPLUS was already defined by the @@ -292,44 +276,22 @@ def _set_logvars(config, logger=None): if logger is None: logger = config.log() - # LOG_TIMESTAMP_TEMPLATE is not required in the conf file, - # so lets first test for that. - log_timestamp_template = config.getstr('config', 'LOG_TIMESTAMP_TEMPLATE', '') - if log_timestamp_template: - # Note: strftime appears to handle if log_timestamp_template - # is a string ie. 'blah' and not a valid set of % directives %Y%m%d, - # it does return the string 'blah', instead of crashing. - # However, I'm still going to test for a valid % directive and - # set a default. It probably is ok to remove the if not block pattern - # test, and not set a default, especially if causing some unintended - # consequences or the pattern is not capturing a valid directive. - # The reality is, the user is expected to have entered a correct - # directive in the conf file. - # This pattern is meant to test for a repeating set of - # case insensitive %(AnyAlphabeticCharacter), ie. %Y%m ... - # The basic pattern is (%+[a-z])+ , %+ allows for 1 or more - # % characters, ie. %%Y, %% is a valid directive. - # (?i) case insensitive, \A begin string \Z end of string - if not re.match(r'(?i)\A(?:(%+[a-z])+)\Z', log_timestamp_template): - logger.warning('Your LOG_TIMESTAMP_TEMPLATE is not ' - 'a valid strftime directive: %s' % repr(log_timestamp_template)) - logger.info('Using the following default: %Y%m%d%H') - log_timestamp_template = '%Y%m%d%H' - date_t = datetime.datetime.now() - if config.getbool('config', 'LOG_TIMESTAMP_USE_DATATIME', False): - if util.is_loop_by_init(config): - date_t = datetime.datetime.strptime(config.getstr('config', - 'INIT_BEG'), - config.getstr('config', - 'INIT_TIME_FMT')) - else: - date_t = datetime.datetime.strptime(config.getstr('config', - 'VALID_BEG'), - config.getstr('config', - 'VALID_TIME_FMT')) - log_filenametimestamp = date_t.strftime(log_timestamp_template) + log_timestamp_template = config.getstr('config', 'LOG_TIMESTAMP_TEMPLATE', + '') + if config.getbool('config', 'LOG_TIMESTAMP_USE_DATATIME', False): + if util.is_loop_by_init(config): + loop_by = 'INIT' + else: + loop_by = 'VALID' + + date_t = datetime.datetime.strptime( + config.getstr('config', f'{loop_by}_BEG'), + config.getstr('config', f'{loop_by}_TIME_FMT') + ) else: - log_filenametimestamp = '' + date_t = datetime.datetime.now() + + log_filenametimestamp = date_t.strftime(log_timestamp_template) log_dir = config.getdir('LOG_DIR') @@ -343,11 +305,14 @@ def _set_logvars(config, logger=None): if config.has_option('config', 'LOG_METPLUS'): user_defined_log_file = True # strinterp will set metpluslog to '' if LOG_METPLUS = is unset. - metpluslog = config.strinterp('config', '{LOG_METPLUS}', - LOG_TIMESTAMP_TEMPLATE=log_filenametimestamp) - - # test if there is any path information, if there is, assUme it is as intended, - # if there is not, than add log_dir. + metpluslog = config.strinterp( + 'config', + '{LOG_METPLUS}', + LOG_TIMESTAMP_TEMPLATE=log_filenametimestamp + ) + + # test if there is any path information, if there is, + # assume it is as intended, if there is not, than add log_dir. if metpluslog: if os.path.basename(metpluslog) == metpluslog: metpluslog = os.path.join(log_dir, metpluslog) @@ -360,12 +325,15 @@ def _set_logvars(config, logger=None): # it out, in case the group wanted a stand alone metplus log filename # template variable. - # If metpluslog_filename includes a path, python joins it intelligently. + # If metpluslog_filename includes a path, python joins it intelligently # Set the metplus log filename. - # strinterp will set metpluslog_filename to '' if LOG_FILENAME_TEMPLATE = + # strinterp will set metpluslog_filename to '' if template is empty if config.has_option('config', 'LOG_FILENAME_TEMPLATE'): - metpluslog_filename = config.strinterp('config', '{LOG_FILENAME_TEMPLATE}', - LOG_TIMESTAMP_TEMPLATE=log_filenametimestamp) + metpluslog_filename = config.strinterp( + 'config', + '{LOG_FILENAME_TEMPLATE}', + LOG_TIMESTAMP_TEMPLATE=log_filenametimestamp + ) else: metpluslog_filename = '' if metpluslog_filename: @@ -374,15 +342,15 @@ def _set_logvars(config, logger=None): metpluslog = '' # Adding LOG_TIMESTAMP to the final configuration file. - logger.info('Adding: config.LOG_TIMESTAMP=%s' % repr(log_filenametimestamp)) + logger.info('Adding LOG_TIMESTAMP=%s' % repr(log_filenametimestamp)) config.set('config', 'LOG_TIMESTAMP', log_filenametimestamp) # Setting LOG_METPLUS in the configuration object # At this point LOG_METPLUS will have a value or '' the empty string. if user_defined_log_file: - logger.info('Replace [config] LOG_METPLUS with %s' % repr(metpluslog)) + logger.info('Replace LOG_METPLUS with %s' % repr(metpluslog)) else: - logger.info('Adding: config.LOG_METPLUS=%s' % repr(metpluslog)) + logger.info('Adding LOG_METPLUS=%s' % repr(metpluslog)) # expand LOG_METPLUS to ensure it is available config.set('config', 'LOG_METPLUS', metpluslog) @@ -501,25 +469,28 @@ def replace_config_from_section(config, section, required=True): return new_config class METplusConfig(ProdConfig): - """!A replacement for the produtil.config.ProdConfig used throughout - the METplus system. You should never need to instantiate one of - these --- the launch() and load() functions do that for you. This - class is the underlying implementation of most of the - functionality described in launch() and load()""" - - # items that are found in these sections will be moved into the [config] section - OLD_SECTIONS = ['dir', - 'exe', - 'filename_templates', - 'regex_pattern', - ] + """! Configuration class to store configuration values read from + METplus config files. + """ + + # items that are found in these sections + # will be moved into the [config] section + OLD_SECTIONS = ( + 'dir', + 'exe', + 'filename_templates', + 'regex_pattern', + ) def __init__(self, conf=None): """!Creates a new METplusConfig - @param conf The configuration file.""" + @param conf The configuration file + """ # set interpolation to None so you can supply filename template # that contain % to config.set - conf = ConfigParser(strict=False, inline_comment_prefixes=(';',), interpolation=None) if (conf is None) else conf + conf = ConfigParser(strict=False, + inline_comment_prefixes=(';',), + interpolation=None) if (conf is None) else conf super().__init__(conf) self._cycle = None self._logger = logging.getLogger('metplus') @@ -530,11 +501,11 @@ def __init__(self, conf=None): # get the OS environment and store it self.env = os.environ.copy() - # add user_env_vars section to hold environment variables defined by the user + # add section to hold environment variables defined by the user self.add_section('user_env_vars') def log(self, sublog=None): - """!Overrides method in ProdConfig + """! Overrides method in ProdConfig If the sublog argument is provided, then the logger will be under that subdomain of the "metplus" logging domain. Otherwise, this METplusConfig's logger @@ -546,7 +517,7 @@ def log(self, sublog=None): return logging.getLogger('metplus.'+sublog) return self._logger - def move_all_to_config_section(self): + def _move_all_to_config_section(self): """! Move all configuration variables that are found in the previously supported sections into the config section. """ @@ -588,6 +559,7 @@ def move_runtime_configs(self): ] more_run_confs = [item for item in self.keys(from_section) if item.startswith('LOG') or item.endswith('BASE')] + # create destination section if it does not exist if not self.has_section(to_section): self._conf.add_section(to_section) @@ -611,42 +583,19 @@ def remove_current_vars(self): if self.has_option('config', current_var): self._conf.remove_option('config', current_var) - def find_section(self, sec, opt): - """! Search through list of previously supported config sections - to find variable requested. This allows the removal of these - sections to consider all of the variables members of the - [config] section. - Args: - @param sec section requested - look in this section first - @param opt configuration variable to find - @returns section heading name or None if not found - """ - # first check the section requested - if self.has_option(sec, opt): - return sec - - # loop through previously supported sections to find variable opt - # return section name if found - for section in self.OLD_SECTIONS: - if self.has_option(section, opt): - return section - - # return None if variable is not found - return None - # override get methods to perform additional error checking def getraw(self, sec, opt, default='', count=0): """ parse parameter and replace any existing parameters referenced with the value (looking in same section, then config, dir, and os environment) returns raw string, preserving {valid?fmt=%Y} blocks - Args: - @param sec: Section in the conf file to look for variable - @param opt: Variable to interpret - @param default: Default value to use if config is not set - @param count: Counter used to stop recursion to prevent infinite - Returns: - Raw string or empty string if function calls itself too many times + + @param sec: Section in the conf file to look for variable + @param opt: Variable to interpret + @param default: Default value to use if config is not set + @param count: Counter used to stop recursion to prevent infinite + @returns Raw string or empty string if function calls itself too + many times """ if count >= 10: self.logger.error("Could not resolve getraw - check for circular " @@ -689,13 +638,14 @@ def getraw(self, sec, opt, default='', count=0): return in_template.replace('//', '/') def check_default(self, sec, name, default): - """!helper function for get methods, report error and raise NoOptionError if - default is not set. If default is set, set the config variable to the - default value so that the value is stored in the final conf - Args: - @param sec section of config - @param name name of config variable - @param default value to use - if set to None, error and raise exception + """! helper function for get methods, report error and raise + NoOptionError if default is not set. + If default is set, set the config variable to the + default value so that the value is stored in the final conf + + @param sec section of config + @param name name of config variable + @param default value to use - if set to None, error/raise exception """ if default is None: raise @@ -720,8 +670,9 @@ def check_default(self, sec, name, default): self.set(sec, name, default) def getexe(self, exe_name): - """!Wraps produtil exe with checks to see if option is set and if - exe actually exists. Returns None if not found instead of exiting""" + """! Wraps produtil exe with checks to see if option is set and if + exe actually exists. Returns None if not found instead of exiting + """ try: exe_path = super().getstr('config', exe_name) except NoOptionError as e: @@ -734,7 +685,7 @@ def getexe(self, exe_name): full_exe_path = shutil.which(exe_path) if full_exe_path is None: - msg = 'Executable {} does not exist at {}'.format(exe_name, exe_path) + msg = f'Executable {exe_name} does not exist at {exe_path}' if self.logger: self.logger.error(msg) else: @@ -745,8 +696,11 @@ def getexe(self, exe_name): self.set('config', exe_name, full_exe_path) return full_exe_path - def getdir(self, dir_name, default=None, morevars=None,taskvars=None, must_exist=False): - """!Wraps produtil getdir and reports an error if it is set to /path/to""" + def getdir(self, dir_name, default=None, morevars=None,taskvars=None, + must_exist=False): + """! Wraps produtil getdir and reports an error if + it is set to /path/to + """ try: dir_path = super().getstr('config', dir_name, default=None, morevars=morevars, taskvars=taskvars) @@ -755,7 +709,8 @@ def getdir(self, dir_name, default=None, morevars=None,taskvars=None, must_exist dir_path = default if '/path/to' in dir_path: - raise ValueError("[config] " + dir_name + " cannot be set to or contain '/path/to'") + raise ValueError(f"{dir_name} cannot be set to " + "or contain '/path/to'") if '\n' in dir_path: raise ValueError(f"Invalid value for [config] {dir_name} " @@ -769,24 +724,28 @@ def getdir(self, dir_name, default=None, morevars=None,taskvars=None, must_exist return dir_path.replace('//', '/') def getdir_nocheck(self, dir_name, default=None): - return super().getstr('config', dir_name, default=default).replace('//', '/') + return super().getstr('config', dir_name, + default=default).replace('//', '/') def getstr_nocheck(self, sec, name, default=None): - # if requested section is in the list of sections that are no longer used - # look in the [config] section for the variable + # if requested section is in the list of sections that are + # no longer used look in the [config] section for the variable if sec in self.OLD_SECTIONS: sec = 'config' return super().getstr(sec, name, default=default).replace('//', '/') - def getstr(self, sec, name, default=None, badtypeok=False, morevars=None, taskvars=None): - """!Wraps produtil getstr. Config variable is checked with a default value of None - because if the config is not set and a default is specified, it will just return - that value. We want to log that a default was used and set it in the config so - it will show up in the final conf that is generated at the end of execution. - If no default was specified in the call, the NoOptionError is raised again. - Replace double forward slash with single to prevent error that occurs if that - is found inside a MET config file (because it considers // the start of a comment + def getstr(self, sec, name, default=None, badtypeok=False, morevars=None, + taskvars=None): + """! Wraps produtil getstr. Config variable is checked with a default + value of None because if the config is not set and a default is + specified, it will just return that value. + We want to log that a default was used and set it in the config so + it will show up in the final conf that is generated at the end of + execution. If no default was specified in the call, + the NoOptionError is raised again. Replace double forward slash + with single to prevent error that occurs if that is found inside + a MET config file because it considers // the start of a comment """ if sec in self.OLD_SECTIONS: sec = 'config' @@ -800,20 +759,25 @@ def getstr(self, sec, name, default=None, badtypeok=False, morevars=None, taskva self.check_default(sec, name, default) return default.replace('//', '/') - def getbool(self, sec, name, default=None, badtypeok=False, morevars=None, taskvars=None): - """!Wraps produtil getbool. Config variable is checked with a default value of None - because if the config is not set and a default is specified, it will just return - that value. We want to log that a default was used and set it in the config so - it will show up in the final conf that is generated at the end of execution. - If no default was specified in the call, the NoOptionError is raised again. - @returns None if value is not a boolean (or yes/no), value if set, default if not set + def getbool(self, sec, name, default=None, badtypeok=False, morevars=None, + taskvars=None): + """! Wraps produtil getbool. Config variable is checked with a + default value of None because if the config is not set and a + default is specified, it will just return that value. + We want to log that a default was used and set it in the config so + it will show up in the final conf that is generated at the end of + execution. If no default was specified in the call, + the NoOptionError is raised again. + @returns None if value is not a boolean (or yes/no), value if set, + default if not set """ if sec in self.OLD_SECTIONS: sec = 'config' try: return super().getbool(sec, name, default=None, - badtypeok=badtypeok, morevars=morevars, taskvars=taskvars) + badtypeok=badtypeok, morevars=morevars, + taskvars=taskvars) except NoOptionError: # config item was not set self.check_default(sec, name, default) @@ -838,17 +802,21 @@ def getbool(self, sec, name, default=None, badtypeok=False, morevars=None, taskv self.logger.error(f"[{sec}] {name} must be an boolean.") return None - def getint(self, sec, name, default=None, badtypeok=False, morevars=None, taskvars=None): + def getint(self, sec, name, default=None, badtypeok=False, morevars=None, + taskvars=None): """!Wraps produtil getint to gracefully report if variable is not set and no default value is specified - @returns Value if set, default of missing value if not set, None if value is an incorrect type""" + @returns Value if set, default of missing value if not set, + None if value is an incorrect type""" if sec in self.OLD_SECTIONS: sec = 'config' try: - # call ProdConfig function with no default set so we can log and set the default + # call ProdConfig function with no default set so + # we can log and set the default return super().getint(sec, name, default=None, - badtypeok=badtypeok, morevars=morevars, taskvars=taskvars) + badtypeok=badtypeok, morevars=morevars, + taskvars=taskvars) # if config variable is not set except NoOptionError: @@ -860,7 +828,7 @@ def getint(self, sec, name, default=None, badtypeok=False, morevars=None, taskva # if invalid value except ValueError: - # check if it was an empty string and return MISSING_DATA_VALUE if so + # check if it was an empty string and return MISSING_DATA_VALUE if super().getstr(sec, name) == '': return util.MISSING_DATA_VALUE @@ -868,17 +836,21 @@ def getint(self, sec, name, default=None, badtypeok=False, morevars=None, taskva self.logger.error(f"[{sec}] {name} must be an integer.") return None - def getfloat(self, sec, name, default=None, badtypeok=False, morevars=None, taskvars=None): + def getfloat(self, sec, name, default=None, badtypeok=False, morevars=None, + taskvars=None): """!Wraps produtil getint to gracefully report if variable is not set and no default value is specified - @returns Value if set, default of missing value if not set, None if value is an incorrect type""" + @returns Value if set, default of missing value if not set, + None if value is an incorrect type""" if sec in self.OLD_SECTIONS: sec = 'config' try: - # call ProdConfig function with no default set so we can log and set the default + # call ProdConfig function with no default set so + # we can log and set the default return super().getfloat(sec, name, default=None, - badtypeok=badtypeok, morevars=morevars, taskvars=taskvars) + badtypeok=badtypeok, morevars=morevars, + taskvars=taskvars) # if config variable is not set except NoOptionError: @@ -890,7 +862,7 @@ def getfloat(self, sec, name, default=None, badtypeok=False, morevars=None, task # if invalid value except ValueError: - # check if it was an empty string and return MISSING_DATA_VALUE if so + # check if it was an empty string and return MISSING_DATA_VALUE if super().getstr(sec, name) == '': return util.MISSING_DATA_VALUE @@ -898,7 +870,8 @@ def getfloat(self, sec, name, default=None, badtypeok=False, morevars=None, task self.logger.error(f"[{sec}] {name} must be a float.") return None - def getseconds(self, sec, name, default=None, badtypeok=False, morevars=None, taskvars=None): + def getseconds(self, sec, name, default=None, badtypeok=False, + morevars=None, taskvars=None): """!Converts time values ending in H, M, or S to seconds""" if sec in self.OLD_SECTIONS: sec = 'config' @@ -907,7 +880,8 @@ def getseconds(self, sec, name, default=None, badtypeok=False, morevars=None, ta # convert value to seconds # Valid options match format 3600, 3600S, 60M, or 1H value = super().getstr(sec, name, default=None, - badtypeok=badtypeok, morevars=morevars, taskvars=taskvars) + badtypeok=badtypeok, morevars=morevars, + taskvars=taskvars) regex_and_multiplier = {r'(-*)(\d+)S': 1, r'(-*)(\d+)M': 60, r'(-*)(\d+)H': 3600, @@ -921,8 +895,8 @@ def getseconds(self, sec, name, default=None, badtypeok=False, morevars=None, ta return int(match.group(2)) * mult # if value is not in an expected format, error and exit - msg = '[{}] {} does not match expected format. '.format(sec, name) +\ - 'Valid options match 3600, 3600S, 60M, or 1H' + msg = (f'[{sec}] {name} does not match expected format. ' + 'Valid options match 3600, 3600S, 60M, or 1H') if self.logger: self.logger.error(msg) else: @@ -935,14 +909,38 @@ def getseconds(self, sec, name, default=None, badtypeok=False, morevars=None, ta self.check_default(sec, name, default) return default + def get_mp_config_name(self, mp_config): + """! Get first name of METplus config variable that is set. + + @param mp_config list of METplus config keys to check. Can also be a + single item + @returns Name of first METplus config name in list that is set in the + METplusConfig object. None if none keys in the list are set. + """ + if not isinstance(mp_config, list): + mp_configs = [mp_config] + else: + mp_configs = mp_config + + for mp_config_name in mp_configs: + if self.has_option('config', mp_config_name): + return mp_config_name + + return None + + class METplusLogFormatter(logging.Formatter): def __init__(self, config): self.default_fmt = config.getraw('config', 'LOG_LINE_FORMAT') - self.info_fmt = config.getraw('config', 'LOG_INFO_LINE_FORMAT', self.default_fmt) - self.debug_fmt = config.getraw('config', 'LOG_DEBUG_LINE_FORMAT', self.default_fmt) - self.error_fmt = config.getraw('config', 'LOG_ERR_LINE_FORMAT', self.default_fmt) + self.info_fmt = config.getraw('config', 'LOG_INFO_LINE_FORMAT', + self.default_fmt) + self.debug_fmt = config.getraw('config', 'LOG_DEBUG_LINE_FORMAT', + self.default_fmt) + self.error_fmt = config.getraw('config', 'LOG_ERR_LINE_FORMAT', + self.default_fmt) super().__init__(fmt=self.default_fmt, - datefmt=config.getraw('config', 'LOG_LINE_DATE_FORMAT'), + datefmt=config.getraw('config', + 'LOG_LINE_DATE_FORMAT'), style='%') def format(self, record): @@ -959,3 +957,1016 @@ def format(self, record): self._style._fmt = self.default_fmt return output + +def validate_configuration_variables(config, force_check=False): + + all_sed_cmds = [] + # check for deprecated config items and warn user to remove/replace them + deprecated_isOK, sed_cmds = check_for_deprecated_config(config) + all_sed_cmds.extend(sed_cmds) + + # check for deprecated env vars in MET config files and warn user to remove/replace them + deprecatedMET_isOK, sed_cmds = check_for_deprecated_met_config(config) + all_sed_cmds.extend(sed_cmds) + + # validate configuration variables + field_isOK, sed_cmds = validate_field_info_configs(config, force_check) + all_sed_cmds.extend(sed_cmds) + + # check that OUTPUT_BASE is not set to the exact same value as INPUT_BASE + inoutbase_isOK = True + input_real_path = os.path.realpath(config.getdir_nocheck('INPUT_BASE', '')) + output_real_path = os.path.realpath(config.getdir('OUTPUT_BASE')) + if input_real_path == output_real_path: + config.logger.error(f"INPUT_BASE AND OUTPUT_BASE are set to the exact same path: {input_real_path}") + config.logger.error("Please change one of these paths to avoid risk of losing input data") + inoutbase_isOK = False + + check_user_environment(config) + + return deprecated_isOK, field_isOK, inoutbase_isOK, deprecatedMET_isOK, all_sed_cmds + +def check_for_deprecated_config(config): + """!Checks user configuration files and reports errors or warnings if any deprecated variable + is found. If an alternate variable name can be suggested, add it to the 'alt' section + If the alternate cannot be literally substituted for the old name, set copy to False + Args: + @config : METplusConfig object to evaluate + Returns: + A tuple containing a boolean if the configuration is suitable to run or not and + if it is not correct, the 2nd item is a list of sed commands that can be run to help + fix the incorrect configuration variables + """ + + # key is the name of the depreacted variable that is no longer allowed in any config files + # value is a dictionary containing information about what to do with the deprecated config + # 'sec' is the section of the config file where the replacement resides, i.e. config, dir, + # filename_templates + # 'alt' is the alternative name for the deprecated config. this can be a single variable name or + # text to describe multiple variables or how to handle it. Set to None to tell the user to + # just remove the variable + # 'copy' is an optional item (defaults to True). set this to False if one cannot simply replace + # the deprecated config variable name with the value in 'alt' + # 'req' is an optional item (defaults to True). this to False to report a warning for the + # deprecated config and allow execution to continue. this is generally no longer used + # because we are requiring users to update the config files. if used, the developer must + # modify the code to handle both variables accordingly + deprecated_dict = { + 'LOOP_BY_INIT' : {'sec' : 'config', 'alt' : 'LOOP_BY', 'copy': False}, + 'LOOP_METHOD' : {'sec' : 'config', 'alt' : 'LOOP_ORDER'}, + 'PREPBUFR_DIR_REGEX' : {'sec' : 'regex_pattern', 'alt' : None}, + 'PREPBUFR_FILE_REGEX' : {'sec' : 'regex_pattern', 'alt' : None}, + 'OBS_INPUT_DIR_REGEX' : {'sec' : 'regex_pattern', 'alt' : 'OBS_POINT_STAT_INPUT_DIR', 'copy': False}, + 'FCST_INPUT_DIR_REGEX' : {'sec' : 'regex_pattern', 'alt' : 'FCST_POINT_STAT_INPUT_DIR', 'copy': False}, + 'FCST_INPUT_FILE_REGEX' : + {'sec' : 'regex_pattern', 'alt' : 'FCST_POINT_STAT_INPUT_TEMPLATE', 'copy': False}, + 'OBS_INPUT_FILE_REGEX' : {'sec' : 'regex_pattern', 'alt' : 'OBS_POINT_STAT_INPUT_TEMPLATE', 'copy': False}, + 'PREPBUFR_DATA_DIR' : {'sec' : 'dir', 'alt' : 'PB2NC_INPUT_DIR'}, + 'PREPBUFR_MODEL_DIR_NAME' : {'sec' : 'dir', 'alt' : 'PB2NC_INPUT_DIR', 'copy': False}, + 'OBS_INPUT_FILE_TMPL' : + {'sec' : 'filename_templates', 'alt' : 'OBS_POINT_STAT_INPUT_TEMPLATE'}, + 'FCST_INPUT_FILE_TMPL' : + {'sec' : 'filename_templates', 'alt' : 'FCST_POINT_STAT_INPUT_TEMPLATE'}, + 'NC_FILE_TMPL' : {'sec' : 'filename_templates', 'alt' : 'PB2NC_OUTPUT_TEMPLATE'}, + 'FCST_INPUT_DIR' : {'sec' : 'dir', 'alt' : 'FCST_POINT_STAT_INPUT_DIR'}, + 'OBS_INPUT_DIR' : {'sec' : 'dir', 'alt' : 'OBS_POINT_STAT_INPUT_DIR'}, + 'REGRID_TO_GRID' : {'sec' : 'config', 'alt' : 'POINT_STAT_REGRID_TO_GRID'}, + 'FCST_HR_START' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, + 'FCST_HR_END' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, + 'FCST_HR_INTERVAL' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, + 'START_DATE' : {'sec' : 'config', 'alt' : 'INIT_BEG or VALID_BEG', 'copy': False}, + 'END_DATE' : {'sec' : 'config', 'alt' : 'INIT_END or VALID_END', 'copy': False}, + 'INTERVAL_TIME' : {'sec' : 'config', 'alt' : 'INIT_INCREMENT or VALID_INCREMENT', 'copy': False}, + 'BEG_TIME' : {'sec' : 'config', 'alt' : 'INIT_BEG or VALID_BEG', 'copy': False}, + 'END_TIME' : {'sec' : 'config', 'alt' : 'INIT_END or VALID_END', 'copy': False}, + 'START_HOUR' : {'sec' : 'config', 'alt' : 'INIT_BEG or VALID_BEG', 'copy': False}, + 'END_HOUR' : {'sec' : 'config', 'alt' : 'INIT_END or VALID_END', 'copy': False}, + 'OBS_BUFR_VAR_LIST' : {'sec' : 'config', 'alt' : 'PB2NC_OBS_BUFR_VAR_LIST'}, + 'TIME_SUMMARY_FLAG' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_FLAG'}, + 'TIME_SUMMARY_BEG' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_BEG'}, + 'TIME_SUMMARY_END' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_END'}, + 'TIME_SUMMARY_VAR_NAMES' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_VAR_NAMES'}, + 'TIME_SUMMARY_TYPE' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_TYPE'}, + 'OVERWRITE_NC_OUTPUT' : {'sec' : 'config', 'alt' : 'PB2NC_SKIP_IF_OUTPUT_EXISTS', 'copy': False}, + 'VERTICAL_LOCATION' : {'sec' : 'config', 'alt' : 'PB2NC_VERTICAL_LOCATION'}, + 'VERIFICATION_GRID' : {'sec' : 'config', 'alt' : 'REGRID_DATA_PLANE_VERIF_GRID'}, + 'WINDOW_RANGE_BEG' : {'sec' : 'config', 'alt' : 'OBS_WINDOW_BEGIN'}, + 'WINDOW_RANGE_END' : {'sec' : 'config', 'alt' : 'OBS_WINDOW_END'}, + 'OBS_EXACT_VALID_TIME' : + {'sec' : 'config', 'alt' : 'OBS_WINDOW_BEGIN and OBS_WINDOW_END', 'copy': False}, + 'FCST_EXACT_VALID_TIME' : + {'sec' : 'config', 'alt' : 'FCST_WINDOW_BEGIN and FCST_WINDOW_END', 'copy': False}, + 'PCP_COMBINE_METHOD' : + {'sec' : 'config', 'alt' : 'FCST_PCP_COMBINE_METHOD and/or OBS_PCP_COMBINE_METHOD', 'copy': False}, + 'FHR_BEG' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, + 'FHR_END' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, + 'FHR_INC' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, + 'FHR_GROUP_BEG' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_[N]', 'copy': False}, + 'FHR_GROUP_END' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_[N]', 'copy': False}, + 'FHR_GROUP_LABELS' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_[N]_LABEL', 'copy': False}, + 'CYCLONE_OUT_DIR' : {'sec' : 'dir', 'alt' : 'CYCLONE_OUTPUT_DIR'}, + 'ENSEMBLE_STAT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'ENSEMBLE_STAT_OUTPUT_DIR'}, + 'EXTRACT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'EXTRACT_TILES_OUTPUT_DIR'}, + 'GRID_STAT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'GRID_STAT_OUTPUT_DIR'}, + 'MODE_OUT_DIR' : {'sec' : 'dir', 'alt' : 'MODE_OUTPUT_DIR'}, + 'MTD_OUT_DIR' : {'sec' : 'dir', 'alt' : 'MTD_OUTPUT_DIR'}, + 'SERIES_INIT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_OUTPUT_DIR'}, + 'SERIES_LEAD_OUT_DIR' : {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_OUTPUT_DIR'}, + 'SERIES_INIT_FILTERED_OUT_DIR' : + {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, + 'SERIES_LEAD_FILTERED_OUT_DIR' : + {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, + 'STAT_ANALYSIS_OUT_DIR' : + {'sec' : 'dir', 'alt' : 'STAT_ANALYSIS_OUTPUT_DIR'}, + 'TCMPR_PLOT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'TCMPR_PLOT_OUTPUT_DIR'}, + 'FCST_MIN_FORECAST' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_MIN'}, + 'FCST_MAX_FORECAST' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_MAX'}, + 'OBS_MIN_FORECAST' : {'sec' : 'config', 'alt' : 'OBS_PCP_COMBINE_MIN_LEAD'}, + 'OBS_MAX_FORECAST' : {'sec' : 'config', 'alt' : 'OBS_PCP_COMBINE_MAX_LEAD'}, + 'FCST_INIT_INTERVAL' : {'sec' : 'config', 'alt' : None}, + 'OBS_INIT_INTERVAL' : {'sec' : 'config', 'alt' : None}, + 'FCST_DATA_INTERVAL' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_DATA_INTERVAL'}, + 'OBS_DATA_INTERVAL' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_DATA_INTERVAL'}, + 'FCST_IS_DAILY_FILE' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_IS_DAILY_FILE'}, + 'OBS_IS_DAILY_FILE' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_IS_DAILY_FILE'}, + 'FCST_TIMES_PER_FILE' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_TIMES_PER_FILE'}, + 'OBS_TIMES_PER_FILE' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_TIMES_PER_FILE'}, + 'FCST_LEVEL' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_INPUT_ACCUMS', 'copy': False}, + 'OBS_LEVEL' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_INPUT_ACCUMS', 'copy': False}, + 'MODE_FCST_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'FCST_MODE_CONV_RADIUS'}, + 'MODE_FCST_CONV_THRESH' : {'sec' : 'config', 'alt' : 'FCST_MODE_CONV_THRESH'}, + 'MODE_FCST_MERGE_FLAG' : {'sec' : 'config', 'alt' : 'FCST_MODE_MERGE_FLAG'}, + 'MODE_FCST_MERGE_THRESH' : {'sec' : 'config', 'alt' : 'FCST_MODE_MERGE_THRESH'}, + 'MODE_OBS_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'OBS_MODE_CONV_RADIUS'}, + 'MODE_OBS_CONV_THRESH' : {'sec' : 'config', 'alt' : 'OBS_MODE_CONV_THRESH'}, + 'MODE_OBS_MERGE_FLAG' : {'sec' : 'config', 'alt' : 'OBS_MODE_MERGE_FLAG'}, + 'MODE_OBS_MERGE_THRESH' : {'sec' : 'config', 'alt' : 'OBS_MODE_MERGE_THRESH'}, + 'MTD_FCST_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'FCST_MTD_CONV_RADIUS'}, + 'MTD_FCST_CONV_THRESH' : {'sec' : 'config', 'alt' : 'FCST_MTD_CONV_THRESH'}, + 'MTD_OBS_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'OBS_MTD_CONV_RADIUS'}, + 'MTD_OBS_CONV_THRESH' : {'sec' : 'config', 'alt' : 'OBS_MTD_CONV_THRESH'}, + 'RM_EXE' : {'sec' : 'exe', 'alt' : 'RM'}, + 'CUT_EXE' : {'sec' : 'exe', 'alt' : 'CUT'}, + 'TR_EXE' : {'sec' : 'exe', 'alt' : 'TR'}, + 'NCAP2_EXE' : {'sec' : 'exe', 'alt' : 'NCAP2'}, + 'CONVERT_EXE' : {'sec' : 'exe', 'alt' : 'CONVERT'}, + 'NCDUMP_EXE' : {'sec' : 'exe', 'alt' : 'NCDUMP'}, + 'EGREP_EXE' : {'sec' : 'exe', 'alt' : 'EGREP'}, + 'ADECK_TRACK_DATA_DIR' : {'sec' : 'dir', 'alt' : 'TC_PAIRS_ADECK_INPUT_DIR'}, + 'BDECK_TRACK_DATA_DIR' : {'sec' : 'dir', 'alt' : 'TC_PAIRS_BDECK_INPUT_DIR'}, + 'MISSING_VAL_TO_REPLACE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_MISSING_VAL_TO_REPLACE'}, + 'MISSING_VAL' : {'sec' : 'config', 'alt' : 'TC_PAIRS_MISSING_VAL'}, + 'TRACK_DATA_SUBDIR_MOD' : {'sec' : 'dir', 'alt' : None}, + 'ADECK_FILE_PREFIX' : {'sec' : 'config', 'alt' : 'TC_PAIRS_ADECK_TEMPLATE', 'copy': False}, + 'BDECK_FILE_PREFIX' : {'sec' : 'config', 'alt' : 'TC_PAIRS_BDECK_TEMPLATE', 'copy': False}, + 'TOP_LEVEL_DIRS' : {'sec' : 'config', 'alt' : 'TC_PAIRS_READ_ALL_FILES'}, + 'TC_PAIRS_DIR' : {'sec' : 'dir', 'alt' : 'TC_PAIRS_OUTPUT_DIR'}, + 'CYCLONE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_CYCLONE'}, + 'STORM_ID' : {'sec' : 'config', 'alt' : 'TC_PAIRS_STORM_ID'}, + 'BASIN' : {'sec' : 'config', 'alt' : 'TC_PAIRS_BASIN'}, + 'STORM_NAME' : {'sec' : 'config', 'alt' : 'TC_PAIRS_STORM_NAME'}, + 'DLAND_FILE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_DLAND_FILE'}, + 'TRACK_TYPE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_REFORMAT_DECK'}, + 'FORECAST_TMPL' : {'sec' : 'filename_templates', 'alt' : 'TC_PAIRS_ADECK_TEMPLATE'}, + 'REFERENCE_TMPL' : {'sec' : 'filename_templates', 'alt' : 'TC_PAIRS_BDECK_TEMPLATE'}, + 'TRACK_DATA_MOD_FORCE_OVERWRITE' : + {'sec' : 'config', 'alt' : 'TC_PAIRS_SKIP_IF_REFORMAT_EXISTS', 'copy': False}, + 'TC_PAIRS_FORCE_OVERWRITE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_SKIP_IF_OUTPUT_EXISTS', 'copy': False}, + 'GRID_STAT_CONFIG' : {'sec' : 'config', 'alt' : 'GRID_STAT_CONFIG_FILE'}, + 'MODE_CONFIG' : {'sec' : 'config', 'alt': 'MODE_CONFIG_FILE'}, + 'FCST_PCP_COMBINE_INPUT_LEVEL': {'sec': 'config', 'alt' : 'FCST_PCP_COMBINE_INPUT_ACCUMS'}, + 'OBS_PCP_COMBINE_INPUT_LEVEL': {'sec': 'config', 'alt' : 'OBS_PCP_COMBINE_INPUT_ACCUMS'}, + 'TIME_METHOD': {'sec': 'config', 'alt': 'LOOP_BY', 'copy': False}, + 'MODEL_DATA_DIR': {'sec': 'dir', 'alt': 'EXTRACT_TILES_GRID_INPUT_DIR'}, + 'STAT_LIST': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_STAT_LIST'}, + 'NLAT': {'sec': 'config', 'alt': 'EXTRACT_TILES_NLAT'}, + 'NLON': {'sec': 'config', 'alt': 'EXTRACT_TILES_NLON'}, + 'DLAT': {'sec': 'config', 'alt': 'EXTRACT_TILES_DLAT'}, + 'DLON': {'sec': 'config', 'alt': 'EXTRACT_TILES_DLON'}, + 'LON_ADJ': {'sec': 'config', 'alt': 'EXTRACT_TILES_LON_ADJ'}, + 'LAT_ADJ': {'sec': 'config', 'alt': 'EXTRACT_TILES_LAT_ADJ'}, + 'OVERWRITE_TRACK': {'sec': 'config', 'alt': 'EXTRACT_TILES_OVERWRITE_TRACK'}, + 'BACKGROUND_MAP': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_BACKGROUND_MAP'}, + 'GFS_FCST_FILE_TMPL': {'sec': 'filename_templates', 'alt': 'FCST_EXTRACT_TILES_INPUT_TEMPLATE'}, + 'GFS_ANLY_FILE_TMPL': {'sec': 'filename_templates', 'alt': 'OBS_EXTRACT_TILES_INPUT_TEMPLATE'}, + 'SERIES_BY_LEAD_FILTERED_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, + 'SERIES_BY_INIT_FILTERED_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, + 'SERIES_BY_LEAD_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_OUTPUT_DIR'}, + 'SERIES_BY_INIT_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_OUTPUT_DIR'}, + 'SERIES_BY_LEAD_GROUP_FCSTS': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_GROUP_FCSTS'}, + 'SERIES_ANALYSIS_BY_LEAD_CONFIG_FILE': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_CONFIG_FILE'}, + 'SERIES_ANALYSIS_BY_INIT_CONFIG_FILE': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_CONFIG_FILE'}, + 'ENSEMBLE_STAT_MET_OBS_ERROR_TABLE': {'sec': 'config', 'alt': 'ENSEMBLE_STAT_MET_OBS_ERR_TABLE'}, + 'VAR_LIST': {'sec': 'config', 'alt': 'BOTH_VAR_NAME BOTH_VAR_LEVELS or SERIES_ANALYSIS_VAR_LIST', 'copy': False}, + 'SERIES_ANALYSIS_VAR_LIST': {'sec': 'config', 'alt': 'BOTH_VAR_NAME BOTH_VAR_LEVELS', 'copy': False}, + 'EXTRACT_TILES_VAR_LIST': {'sec': 'config', 'alt': ''}, + 'STAT_ANALYSIS_LOOKIN_DIR': {'sec': 'dir', 'alt': 'MODEL1_STAT_ANALYSIS_LOOKIN_DIR'}, + 'VALID_HOUR_METHOD': {'sec': 'config', 'alt': None}, + 'VALID_HOUR_BEG': {'sec': 'config', 'alt': None}, + 'VALID_HOUR_END': {'sec': 'config', 'alt': None}, + 'VALID_HOUR_INCREMENT': {'sec': 'config', 'alt': None}, + 'INIT_HOUR_METHOD': {'sec': 'config', 'alt': None}, + 'INIT_HOUR_BEG': {'sec': 'config', 'alt': None}, + 'INIT_HOUR_END': {'sec': 'config', 'alt': None}, + 'INIT_HOUR_INCREMENT': {'sec': 'config', 'alt': None}, + 'STAT_ANALYSIS_CONFIG': {'sec': 'config', 'alt': 'STAT_ANALYSIS_CONFIG_FILE'}, + 'JOB_NAME': {'sec': 'config', 'alt': 'STAT_ANALYSIS_JOB_NAME'}, + 'JOB_ARGS': {'sec': 'config', 'alt': 'STAT_ANALYSIS_JOB_ARGS'}, + 'FCST_LEAD': {'sec': 'config', 'alt': 'FCST_LEAD_LIST'}, + 'FCST_VAR_NAME': {'sec': 'config', 'alt': 'FCST_VAR_LIST'}, + 'FCST_VAR_LEVEL': {'sec': 'config', 'alt': 'FCST_VAR_LEVEL_LIST'}, + 'OBS_VAR_NAME': {'sec': 'config', 'alt': 'OBS_VAR_LIST'}, + 'OBS_VAR_LEVEL': {'sec': 'config', 'alt': 'OBS_VAR_LEVEL_LIST'}, + 'REGION': {'sec': 'config', 'alt': 'VX_MASK_LIST'}, + 'INTERP': {'sec': 'config', 'alt': 'INTERP_LIST'}, + 'INTERP_PTS': {'sec': 'config', 'alt': 'INTERP_PTS_LIST'}, + 'CONV_THRESH': {'sec': 'config', 'alt': 'CONV_THRESH_LIST'}, + 'FCST_THRESH': {'sec': 'config', 'alt': 'FCST_THRESH_LIST'}, + 'LINE_TYPE': {'sec': 'config', 'alt': 'LINE_TYPE_LIST'}, + 'STAT_ANALYSIS_DUMP_ROW_TMPL': {'sec': 'filename_templates', 'alt': 'STAT_ANALYSIS_DUMP_ROW_TEMPLATE'}, + 'STAT_ANALYSIS_OUT_STAT_TMPL': {'sec': 'filename_templates', 'alt': 'STAT_ANALYSIS_OUT_STAT_TEMPLATE'}, + 'PLOTTING_SCRIPTS_DIR': {'sec': 'dir', 'alt': 'MAKE_PLOTS_SCRIPTS_DIR'}, + 'STAT_FILES_INPUT_DIR': {'sec': 'dir', 'alt': 'MAKE_PLOTS_INPUT_DIR'}, + 'PLOTTING_OUTPUT_DIR': {'sec': 'dir', 'alt': 'MAKE_PLOTS_OUTPUT_DIR'}, + 'VERIF_CASE': {'sec': 'config', 'alt': 'MAKE_PLOTS_VERIF_CASE'}, + 'VERIF_TYPE': {'sec': 'config', 'alt': 'MAKE_PLOTS_VERIF_TYPE'}, + 'PLOT_TIME': {'sec': 'config', 'alt': 'DATE_TIME'}, + 'MODEL_NAME': {'sec': 'config', 'alt': 'MODEL'}, + 'MODEL_OBS_NAME': {'sec': 'config', 'alt': 'MODEL_OBTYPE'}, + 'MODEL_STAT_DIR': {'sec': 'dir', 'alt': 'MODEL_STAT_ANALYSIS_LOOKIN_DIR'}, + 'MODEL_NAME_ON_PLOT': {'sec': 'config', 'alt': 'MODEL_REFERENCE_NAME'}, + 'REGION_LIST': {'sec': 'config', 'alt': 'VX_MASK_LIST'}, + 'PLOT_STATS_LIST': {'sec': 'config', 'alt': 'MAKE_PLOT_STATS_LIST'}, + 'CI_METHOD': {'sec': 'config', 'alt': 'MAKE_PLOTS_CI_METHOD'}, + 'VERIF_GRID': {'sec': 'config', 'alt': 'MAKE_PLOTS_VERIF_GRID'}, + 'EVENT_EQUALIZATION': {'sec': 'config', 'alt': 'MAKE_PLOTS_EVENT_EQUALIZATION'}, + 'MTD_CONFIG': {'sec': 'config', 'alt': 'MTD_CONFIG_FILE'}, + 'CLIMO_GRID_STAT_INPUT_DIR': {'sec': 'dir', 'alt': 'GRID_STAT_CLIMO_MEAN_INPUT_DIR'}, + 'CLIMO_GRID_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', 'alt': 'GRID_STAT_CLIMO_MEAN_INPUT_TEMPLATE'}, + 'CLIMO_POINT_STAT_INPUT_DIR': {'sec': 'dir', 'alt': 'POINT_STAT_CLIMO_MEAN_INPUT_DIR'}, + 'CLIMO_POINT_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', 'alt': 'POINT_STAT_CLIMO_MEAN_INPUT_TEMPLATE'}, + 'GEMPAKTOCF_CLASSPATH': {'sec': 'exe', 'alt': 'GEMPAKTOCF_JAR', 'copy': False}, + 'CUSTOM_INGEST__OUTPUT_DIR': {'sec': 'dir', 'alt': 'PY_EMBED_INGEST__OUTPUT_DIR'}, + 'CUSTOM_INGEST__OUTPUT_TEMPLATE': {'sec': 'filename_templates', 'alt': 'PY_EMBED_INGEST__OUTPUT_TEMPLATE'}, + 'CUSTOM_INGEST__OUTPUT_GRID': {'sec': 'config', 'alt': 'PY_EMBED_INGEST__OUTPUT_GRID'}, + 'CUSTOM_INGEST__SCRIPT': {'sec': 'config', 'alt': 'PY_EMBED_INGEST__SCRIPT'}, + 'CUSTOM_INGEST__TYPE': {'sec': 'config', 'alt': 'PY_EMBED_INGEST__TYPE'}, + 'TC_STAT_RUN_VIA': {'sec': 'config', 'alt': 'TC_STAT_CONFIG_FILE', + 'copy': False}, + 'TC_STAT_CMD_LINE_JOB': {'sec': 'config', 'alt': 'TC_STAT_JOB_ARGS'}, + 'TC_STAT_JOBS_LIST': {'sec': 'config', 'alt': 'TC_STAT_JOB_ARGS'}, + 'EXTRACT_TILES_OVERWRITE_TRACK': {'sec': 'config', + 'alt': 'EXTRACT_TILES_SKIP_IF_OUTPUT_EXISTS', + 'copy': False}, + 'EXTRACT_TILES_PAIRS_INPUT_DIR': {'sec': 'dir', + 'alt': 'EXTRACT_TILES_STAT_INPUT_DIR', + 'copy': False}, + 'EXTRACT_TILES_FILTERED_OUTPUT_TEMPLATE': {'sec': 'filename_template', + 'alt': 'EXTRACT_TILES_STAT_INPUT_TEMPLATE',}, + 'EXTRACT_TILES_GRID_INPUT_DIR': {'sec': 'dir', + 'alt': 'FCST_EXTRACT_TILES_INPUT_DIR' + 'and ' + 'OBS_EXTRACT_TILES_INPUT_DIR', + 'copy': False}, + 'SERIES_ANALYSIS_FILTER_OPTS': {'sec': 'config', + 'alt': 'TC_STAT_JOB_ARGS', + 'copy': False}, + 'SERIES_ANALYSIS_INPUT_DIR': {'sec': 'dir', + 'alt': 'FCST_SERIES_ANALYSIS_INPUT_DIR ' + 'and ' + 'OBS_SERIES_ANALYSIS_INPUT_DIR'}, + 'FCST_SERIES_ANALYSIS_TILE_INPUT_TEMPLATE': {'sec': 'filename_templates', + 'alt': 'FCST_SERIES_ANALYSIS_INPUT_TEMPLATE '}, + 'OBS_SERIES_ANALYSIS_TILE_INPUT_TEMPLATE': {'sec': 'filename_templates', + 'alt': 'OBS_SERIES_ANALYSIS_INPUT_TEMPLATE '}, + 'EXTRACT_TILES_STAT_INPUT_DIR': {'sec': 'dir', + 'alt': 'EXTRACT_TILES_TC_STAT_INPUT_DIR',}, + 'EXTRACT_TILES_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', + 'alt': 'EXTRACT_TILES_TC_STAT_INPUT_TEMPLATE',}, + 'SERIES_ANALYSIS_STAT_INPUT_DIR': {'sec': 'dir', + 'alt': 'SERIES_ANALYSIS_TC_STAT_INPUT_DIR', }, + 'SERIES_ANALYSIS_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', + 'alt': 'SERIES_ANALYSIS_TC_STAT_INPUT_TEMPLATE', }, + } + + # template '' : {'sec' : '', 'alt' : '', 'copy': True}, + + logger = config.logger + + # create list of errors and warnings to report for deprecated configs + e_list = [] + w_list = [] + all_sed_cmds = [] + + for old, depr_info in deprecated_dict.items(): + if isinstance(depr_info, dict): + + # check if is found in the old item, use regex to find variables if found + if '' in old: + old_regex = old.replace('', r'(\d+)') + indices = find_indices_in_config_section(old_regex, + config, + index_index=1).keys() + for index in indices: + old_with_index = old.replace('', index) + if depr_info['alt']: + alt_with_index = depr_info['alt'].replace('', index) + else: + alt_with_index = '' + + handle_deprecated(old_with_index, alt_with_index, depr_info, + config, all_sed_cmds, w_list, e_list) + else: + handle_deprecated(old, depr_info['alt'], depr_info, + config, all_sed_cmds, w_list, e_list) + + + # check all templates and error if any deprecated tags are used + # value of dict is replacement tag, set to None if no replacement exists + # deprecated tags: region (replace with basin) + deprecated_tags = {'region' : 'basin'} + template_vars = config.keys('config') + template_vars = [tvar for tvar in template_vars if tvar.endswith('_TEMPLATE')] + for temp_var in template_vars: + template = config.getraw('filename_templates', temp_var) + tags = get_tags(template) + + for depr_tag, replace_tag in deprecated_tags.items(): + if depr_tag in tags: + e_msg = 'Deprecated tag {{{}}} found in {}.'.format(depr_tag, + temp_var) + if replace_tag is not None: + e_msg += ' Replace with {{{}}}'.format(replace_tag) + + e_list.append(e_msg) + + # if any warning exist, report them + if w_list: + for warning_msg in w_list: + logger.warning(warning_msg) + + # if any errors exist, report them and exit + if e_list: + logger.error('DEPRECATED CONFIG ITEMS WERE FOUND. ' +\ + 'PLEASE REMOVE/REPLACE THEM FROM CONFIG FILES') + for error_msg in e_list: + logger.error(error_msg) + return False, all_sed_cmds + + return True, [] + +def check_for_deprecated_met_config(config): + sed_cmds = [] + all_good = True + + # set CURRENT_* METplus variables in case they are referenced in a + # METplus config variable and not already set + for fcst_or_obs in ['FCST', 'OBS']: + for name_or_level in ['NAME', 'LEVEL']: + current_var = f'CURRENT_{fcst_or_obs}_{name_or_level}' + if not config.has_option('config', current_var): + config.set('config', current_var, '') + + # check if *_CONFIG_FILE if set in the METplus config file and check for + # deprecated environment variables in those files + met_config_keys = [key for key in config.keys('config') + if key.endswith('CONFIG_FILE')] + + for met_config_key in met_config_keys: + met_tool = met_config_key.replace('_CONFIG_FILE', '') + + # get custom loop list to check if multiple config files are used based on the custom string + custom_list = get_custom_string_list(config, met_tool) + + for custom_string in custom_list: + met_config = config.getraw('config', met_config_key) + if not met_config: + continue + + met_config_file = do_string_sub(met_config, custom=custom_string) + + if not check_for_deprecated_met_config_file(config, met_config_file, sed_cmds, met_tool): + all_good = False + + return all_good, sed_cmds + +def check_for_deprecated_met_config_file(config, met_config, sed_cmds, met_tool): + + all_good = True + if not os.path.exists(met_config): + config.logger.error(f"Config file does not exist: {met_config}") + return False + + deprecated_met_list = ['MET_VALID_HHMM', 'GRID_VX', 'CONFIG_DIR'] + deprecated_output_prefix_list = ['FCST_VAR', 'OBS_VAR'] + config.logger.debug(f"Checking for deprecated environment variables in: {met_config}") + + with open(met_config, 'r') as file_handle: + lines = file_handle.read().splitlines() + + for line in lines: + for deprecated_item in deprecated_met_list: + if '${' + deprecated_item + '}' in line: + all_good = False + config.logger.error("Please remove deprecated environment variable " + f"${{{deprecated_item}}} found in MET config file: " + f"{met_config}") + + if deprecated_item == 'MET_VALID_HHMM' and 'file_name' in line: + config.logger.error(f"Set {met_tool}_CLIMO_MEAN_INPUT_[DIR/TEMPLATE] in a " + "METplus config file to set CLIMO_MEAN_FILE in a MET config") + new_line = " file_name = [ ${CLIMO_MEAN_FILE} ];" + + # escape [ and ] because they are special characters in sed commands + old_line = line.rstrip().replace('[', r'\[').replace(']', r'\]') + + sed_cmds.append(f"sed -i 's|^{old_line}|{new_line}|g' {met_config}") + add_line = f"{met_tool}_CLIMO_MEAN_INPUT_TEMPLATE" + sed_cmds.append(f"#Add {add_line}") + break + + if 'to_grid' in line: + config.logger.error("MET to_grid variable should reference " + "${REGRID_TO_GRID} environment variable") + new_line = " to_grid = ${REGRID_TO_GRID};" + + # escape [ and ] because they are special characters in sed commands + old_line = line.rstrip().replace('[', r'\[').replace(']', r'\]') + + sed_cmds.append(f"sed -i 's|^{old_line}|{new_line}|g' {met_config}") + config.logger.info(f"Be sure to set {met_tool}_REGRID_TO_GRID to the correct value.") + add_line = f"{met_tool}_REGRID_TO_GRID" + sed_cmds.append(f"#Add {add_line}") + break + + + for deprecated_item in deprecated_output_prefix_list: + # if deprecated item found in output prefix or to_grid line, replace line to use + # env var OUTPUT_PREFIX or REGRID_TO_GRID + if '${' + deprecated_item + '}' in line and 'output_prefix' in line: + config.logger.error("output_prefix variable should reference " + "${OUTPUT_PREFIX} environment variable") + new_line = "output_prefix = \"${OUTPUT_PREFIX}\";" + + # escape [ and ] because they are special characters in sed commands + old_line = line.rstrip().replace('[', r'\[').replace(']', r'\]') + + sed_cmds.append(f"sed -i 's|^{old_line}|{new_line}|g' {met_config}") + config.logger.info(f"You will need to add {met_tool}_OUTPUT_PREFIX to the METplus config file" + f" that sets {met_tool}_CONFIG_FILE. Set it to:") + output_prefix = _replace_output_prefix(line) + add_line = f"{met_tool}_OUTPUT_PREFIX = {output_prefix}" + config.logger.info(add_line) + sed_cmds.append(f"#Add {add_line}") + all_good = False + break + + return all_good + +def validate_field_info_configs(config, force_check=False): + """!Verify that config variables with _VAR_ in them are valid. Returns True if all are valid. + Returns False if any items are invalid""" + + variable_extensions = ['NAME', 'LEVELS', 'THRESH', 'OPTIONS'] + all_good = True, [] + + if skip_field_info_validation(config) and not force_check: + return True, [] + + # keep track of all sed commands to replace config variable names + all_sed_cmds = [] + + for ext in variable_extensions: + # find all _VAR_ keys in the conf files + data_types_and_indices = find_indices_in_config_section(r"(\w+)_VAR(\d+)_"+ext, + config, + index_index=2, + id_index=1) + + # if BOTH_VAR_ is used, set FCST and OBS to the same value + # if FCST or OBS is used, the other must be present as well + # if BOTH and either FCST or OBS are set, report an error + # get other data type + for index, data_type_list in data_types_and_indices.items(): + + is_valid, err_msgs, sed_cmds = is_var_item_valid(data_type_list, index, ext, config) + if not is_valid: + for err_msg in err_msgs: + config.logger.error(err_msg) + all_sed_cmds.extend(sed_cmds) + all_good = False + + # make sure FCST and OBS have the same number of levels if coming from separate variables + elif ext == 'LEVELS' and all(item in ['FCST', 'OBS'] for item in data_type_list): + fcst_levels = getlist(config.getraw('config', f"FCST_VAR{index}_LEVELS", '')) + + # add empty string if no levels are found because python embedding items do not need + # to include a level, but the other item may have a level and the numbers need to match + if not fcst_levels: + fcst_levels.append('') + + obs_levels = getlist(config.getraw('config', f"OBS_VAR{index}_LEVELS", '')) + if not obs_levels: + obs_levels.append('') + + if len(fcst_levels) != len(obs_levels): + config.logger.error(f"FCST_VAR{index}_LEVELS and OBS_VAR{index}_LEVELS do not have " + "the same number of elements") + all_good = False + + return all_good, all_sed_cmds + +def check_user_environment(config): + """!Check if any environment variables set in [user_env_vars] are already set in + the user's environment. Warn them that it will be overwritten from the conf if it is""" + if not config.has_section('user_env_vars'): + return + + for env_var in config.keys('user_env_vars'): + if env_var in os.environ: + msg = '{} is already set in the environment. '.format(env_var) +\ + 'Overwriting from conf file' + config.logger.warning(msg) + +def find_indices_in_config_section(regex, config, sec='config', + index_index=1, id_index=None): + """! Use regular expression to get all config variables that match and + are set in the user's configuration. This is used to handle config + variables that have multiple indices, i.e. FCST_VAR1_NAME, FCST_VAR2_NAME, + etc. + + @param regex regular expression to use to find variables + @param config METplusConfig object to search + @param sec (optional) config file section to search. Defaults to config + @param index_index 1 based number that is the regex match index for the + index number (default is 1) + @param id_index 1 based number that is the regex match index for the + identifier. Defaults to None which does not extract an indentifier + + number and the first match is used as an identifier + @returns dictionary where keys are the index number and the value is a + list of identifiers (if noID=True) or a list containing None + """ + # regex expression must have 2 () items and the 2nd item must be the index + all_conf = config.keys(sec) + indices = {} + regex = re.compile(regex) + for conf in all_conf: + result = regex.match(conf) + if result is not None: + index = result.group(index_index) + if id_index: + identifier = result.group(id_index) + else: + identifier = None + + if index not in indices: + indices[index] = [identifier] + else: + indices[index].append(identifier) + + return indices + +def handle_deprecated(old, alt, depr_info, config, all_sed_cmds, w_list, e_list): + sec = depr_info['sec'] + config_files = config.getstr('config', 'CONFIG_INPUT', '').split(',') + # if deprecated config item is found + if config.has_option(sec, old): + # if it is not required to remove, add to warning list + if 'req' in depr_info.keys() and depr_info['req'] is False: + msg = '[{}] {} is deprecated and will be '.format(sec, old) + \ + 'removed in a future version of METplus' + if alt: + msg += ". Please replace with {}".format(alt) + w_list.append(msg) + # if it is required to remove, add to error list + else: + if not alt: + e_list.append("[{}] {} should be removed".format(sec, old)) + else: + e_list.append("[{}] {} should be replaced with {}".format(sec, old, alt)) + + if 'copy' not in depr_info.keys() or depr_info['copy']: + for config_file in config_files: + all_sed_cmds.append(f"sed -i 's|^{old}|{alt}|g' {config_file}") + all_sed_cmds.append(f"sed -i 's|{{{old}}}|{{{alt}}}|g' {config_file}") + +def get_custom_string_list(config, met_tool): + var_name = 'CUSTOM_LOOP_LIST' + custom_loop_list = config.getstr_nocheck('config', + f'{met_tool.upper()}_{var_name}', + config.getstr_nocheck('config', + var_name, + '')) + custom_loop_list = getlist(custom_loop_list) + if not custom_loop_list: + custom_loop_list.append('') + + return custom_loop_list + +def _replace_output_prefix(line): + op_replacements = {'${MODEL}': '{MODEL}', + '${FCST_VAR}': '{CURRENT_FCST_NAME}', + '${OBTYPE}': '{OBTYPE}', + '${OBS_VAR}': '{CURRENT_OBS_NAME}', + '${LEVEL}': '{CURRENT_FCST_LEVEL}', + '${FCST_TIME}': '{lead?fmt=%3H}', + } + prefix = line.split('=')[1].strip().rstrip(';').strip('"') + for key, value, in op_replacements.items(): + prefix = prefix.replace(key, value) + + return prefix + +def parse_var_list(config, time_info=None, data_type=None, met_tool=None, + levels_as_list=False): + """ read conf items and populate list of dictionaries containing + information about each variable to be compared + + @param config: METplusConfig object + @param time_info: time object for string sub, optional + @param data_type: data type to find. Can be FCST, OBS, or ENS. + If not set, get FCST/OBS/BOTH + @param met_tool: optional name of MET tool to look for wrapper + specific var items + @param levels_as_list If true, store levels and output names as + a list instead of creating a field info dict for each name/level + @returns list of dictionaries with variable information + """ + + # validate configs again in case wrapper is not running from run_metplus + # this does not need to be done if parsing a specific data type, + # i.e. ENS or FCST + if data_type is None: + if not validate_field_info_configs(config)[0]: + return [] + elif data_type == 'BOTH': + config.logger.error("Cannot request BOTH explicitly in parse_var_list") + return [] + + # var_list is a list containing an list of dictionaries + var_list = [] + + # if specific data type is requested, only get that type + if data_type: + data_types = [data_type] + # otherwise get both FCST and OBS + else: + data_types = ['FCST', 'OBS'] + + # get indices of VAR items for data type and/or met tool + indices = [] + if met_tool: + indices = find_var_name_indices(config, data_types, met_tool).keys() + if not indices: + indices = find_var_name_indices(config, data_types).keys() + + # get config name prefixes for each data type to find + dt_search_prefixes = {} + for current_type in data_types: + # get list of variable prefixes to search + prefixes = get_field_search_prefixes(current_type, met_tool) + dt_search_prefixes[current_type] = prefixes + + # loop over all possible variables and add them to list + for index in indices: + field_info_list = [] + for current_type in data_types: + # get dictionary of existing config variables to use + search_prefixes = dt_search_prefixes[current_type] + field_configs = get_field_config_variables(config, + index, + search_prefixes) + + field_info = format_var_items(field_configs, time_info) + if not isinstance(field_info, dict): + config.logger.error(f'Could not process {current_type}_' + f'VAR{index} variables: {field_info}') + continue + + field_info['data_type'] = current_type.lower() + field_info_list.append(field_info) + + # check that all fields types were found + if not field_info_list or len(data_types) != len(field_info_list): + continue + + # check if number of levels for each field type matches + n_levels = len(field_info_list[0]['levels']) + if len(data_types) > 1: + if (n_levels != len(field_info_list[1]['levels'])): + continue + + # if requested, put all field levels in a single item + if levels_as_list: + var_dict = {} + for field_info in field_info_list: + current_type = field_info.get('data_type') + var_dict[f"{current_type}_name"] = field_info.get('name') + var_dict[f"{current_type}_level"] = field_info.get('levels') + var_dict[f"{current_type}_thresh"] = field_info.get('thresh') + var_dict[f"{current_type}_extra"] = field_info.get('extra') + var_dict[f"{current_type}_output_name"] = field_info.get('output_names') + + var_dict['index'] = index + var_list.append(var_dict) + continue + + # loop over levels and add all values to output dictionary + for level_index in range(n_levels): + var_dict = {} + + # get level values to use for string substitution in name + # used for python embedding calls that read the level value + sub_info = {} + for field_info in field_info_list: + dt_level = f"{field_info.get('data_type')}_level" + sub_info[dt_level] = field_info.get('levels')[level_index] + + for field_info in field_info_list: + current_type = field_info.get('data_type') + name = field_info.get('name') + level = field_info.get('levels')[level_index] + thresh = field_info.get('thresh') + extra = field_info.get('extra') + output_name = field_info.get('output_names')[level_index] + + # substitute level in name if filename template is specified + subbed_name = do_string_sub(name, + skip_missing_tags=True, + **sub_info) + + var_dict[f"{current_type}_name"] = subbed_name + var_dict[f"{current_type}_level"] = level + var_dict[f"{current_type}_thresh"] = thresh + var_dict[f"{current_type}_extra"] = extra + var_dict[f"{current_type}_output_name"] = output_name + + var_dict['index'] = index + var_list.append(var_dict) + + # extra debugging information used for developer debugging only + ''' + for v in var_list: + config.logger.debug(f"VAR{v['index']}:") + if 'fcst_name' in v.keys(): + config.logger.debug(" fcst_name:"+v['fcst_name']) + config.logger.debug(" fcst_level:"+v['fcst_level']) + if 'fcst_thresh' in v.keys(): + config.logger.debug(" fcst_thresh:"+str(v['fcst_thresh'])) + if 'fcst_extra' in v.keys(): + config.logger.debug(" fcst_extra:"+v['fcst_extra']) + if 'fcst_output_name' in v.keys(): + config.logger.debug(" fcst_output_name:"+v['fcst_output_name']) + if 'obs_name' in v.keys(): + config.logger.debug(" obs_name:"+v['obs_name']) + config.logger.debug(" obs_level:"+v['obs_level']) + if 'obs_thresh' in v.keys(): + config.logger.debug(" obs_thresh:"+str(v['obs_thresh'])) + if 'obs_extra' in v.keys(): + config.logger.debug(" obs_extra:"+v['obs_extra']) + if 'obs_output_name' in v.keys(): + config.logger.debug(" obs_output_name:"+v['obs_output_name']) + if 'ens_name' in v.keys(): + config.logger.debug(" ens_name:"+v['ens_name']) + config.logger.debug(" ens_level:"+v['ens_level']) + if 'ens_thresh' in v.keys(): + config.logger.debug(" ens_thresh:"+str(v['ens_thresh'])) + if 'ens_extra' in v.keys(): + config.logger.debug(" ens_extra:"+v['ens_extra']) + if 'ens_output_name' in v.keys(): + config.logger.debug(" ens_output_name:"+v['ens_output_name']) + ''' + return sorted(var_list, key=lambda x: x['index']) + +def find_var_name_indices(config, data_types, met_tool=None): + data_type_regex = f"{'|'.join(data_types)}" + + # if data_types includes FCST or OBS, also search for BOTH + if any([item for item in ['FCST', 'OBS'] if item in data_types]): + data_type_regex += '|BOTH' + + regex_string = f"({data_type_regex})" + + # if MET tool is specified, get tool specific items + if met_tool: + regex_string += f"_{met_tool.upper()}" + + regex_string += r"_VAR(\d+)_(NAME|INPUT_FIELD_NAME|FIELD_NAME)" + + # find all _VAR_NAME keys in the conf files + return find_indices_in_config_section(regex_string, + config, + index_index=2, + id_index=1) + +def skip_field_info_validation(config): + """!Check config to see if having corresponding FCST/OBS variables is necessary. If process list only + contains reformatter wrappers, don't validate field info. Also, if MTD is in the process list and + it is configured to only process either FCST or OBS, validation is unnecessary.""" + + reformatters = ['PCPCombine', 'RegridDataPlane'] + process_list = [item[0] for item in get_process_list(config)] + + # if running MTD in single mode, you don't need matching FCST/OBS + if 'MTD' in process_list and config.getbool('config', 'MTD_SINGLE_RUN'): + return True + + # if running any app other than the reformatters, you need matching FCST/OBS, so don't skip + if [item for item in process_list if item not in reformatters]: + return False + + return True + +def get_process_list(config): + """!Read process list, Extract instance string if specified inside + parenthesis. Remove dashes/underscores and change to lower case, + then map the name to the correct wrapper name + + @param config METplusConfig object to read PROCESS_LIST value + @returns list of tuple containing process name and instance identifier + (None if no instance was set) + """ + # get list of processes + process_list = getlist(config.getstr('config', 'PROCESS_LIST')) + + out_process_list = [] + # for each item remove dashes, underscores, and cast to lower-case + for process in process_list: + # if instance is specified, extract the text inside parenthesis + match = re.match(r'(.*)\((.*)\)', process) + if match: + instance = match.group(2) + process_name = match.group(1) + else: + instance = None + process_name = process + + wrapper_name = get_wrapper_name(process_name) + if wrapper_name is None: + config.logger.warning(f"PROCESS_LIST item {process_name} " + "may be invalid.") + wrapper_name = process_name + + # if MakePlots is in process list, remove it because + # it will be called directly from StatAnalysis + if wrapper_name == 'MakePlots': + continue + + out_process_list.append((wrapper_name, instance)) + + return out_process_list + +def get_field_search_prefixes(data_type, met_tool=None): + """! Get list of prefixes to search for field variables. + + @param data_type type of field to search for, i.e. FCST, OBS, ENS, etc. + Check for BOTH_ variables first only if data type is FCST or OBS + @param met_tool name of tool to search for variable or None if looking + for generic field info + @returns list of prefixes to search, i.e. [BOTH_, FCST_] or + [ENS_] or [BOTH_GRID_STAT_, OBS_GRID_STAT_] + """ + search_prefixes = [] + var_strings = [] + + # if met tool name is set, prioritize + # wrapper-specific configs before generic configs + if met_tool: + var_strings.append(f'{met_tool.upper()}_') + + var_strings.append('') + + for var_string in var_strings: + search_prefixes.append(f"{data_type}_{var_string}") + + # if looking for FCST or OBS, also check for BOTH prefix + if data_type in ['FCST', 'OBS']: + search_prefixes.append(f"BOTH_{var_string}") + + return search_prefixes + +def is_var_item_valid(item_list, index, ext, config): + """!Given a list of data types (FCST, OBS, ENS, or BOTH) check if the + combination is valid. + If BOTH is found, FCST and OBS should not be found. + If FCST or OBS is found, the other must also be found. + @param item_list list of data types that were found for a given index + @param index number following _VAR in the variable name + @param ext extension to check, i.e. NAME, LEVELS, THRESH, or OPTIONS + @param config METplusConfig instance + @returns tuple containing boolean if var item is valid, list of error + messages and list of sed commands to help the user update their old + configuration files + """ + + full_ext = f"_VAR{index}_{ext}" + msg = [] + sed_cmds = [] + if 'BOTH' in item_list and ('FCST' in item_list or 'OBS' in item_list): + + msg.append(f"Cannot set FCST{full_ext} or OBS{full_ext} if BOTH{full_ext} is set.") + elif ext == 'THRESH': + # allow thresholds unless BOTH and (FCST or OBS) are set + pass + + elif 'FCST' in item_list and 'OBS' not in item_list: + # if FCST level has 1 item and OBS name is a python embedding script, + # don't report error + level_list = getlist(config.getraw('config', + f'FCST_VAR{index}_LEVELS', + '')) + other_name = config.getraw('config', f'OBS_VAR{index}_NAME', '') + skip_error_for_py_embed = ext == 'LEVELS' and is_python_script(other_name) and len(level_list) == 1 + # do not report error for OPTIONS since it isn't required to be the same length + if ext not in ['OPTIONS'] and not skip_error_for_py_embed: + msg.append(f"If FCST{full_ext} is set, you must either set OBS{full_ext} or " + f"change FCST{full_ext} to BOTH{full_ext}") + + config_files = config.getstr('config', 'CONFIG_INPUT', '').split(',') + for config_file in config_files: + sed_cmds.append(f"sed -i 's|^FCST{full_ext}|BOTH{full_ext}|g' {config_file}") + sed_cmds.append(f"sed -i 's|{{FCST{full_ext}}}|{{BOTH{full_ext}}}|g' {config_file}") + + elif 'OBS' in item_list and 'FCST' not in item_list: + # if OBS level has 1 item and FCST name is a python embedding script, + # don't report error + level_list = getlist(config.getraw('config', + f'OBS_VAR{index}_LEVELS', + '')) + other_name = config.getraw('config', f'FCST_VAR{index}_NAME', '') + skip_error_for_py_embed = ext == 'LEVELS' and is_python_script(other_name) and len(level_list) == 1 + + if ext not in ['OPTIONS'] and not skip_error_for_py_embed: + msg.append(f"If OBS{full_ext} is set, you must either set FCST{full_ext} or " + f"change OBS{full_ext} to BOTH{full_ext}") + + config_files = config.getstr('config', 'CONFIG_INPUT', '').split(',') + for config_file in config_files: + sed_cmds.append(f"sed -i 's|^OBS{full_ext}|BOTH{full_ext}|g' {config_file}") + sed_cmds.append(f"sed -i 's|{{OBS{full_ext}}}|{{BOTH{full_ext}}}|g' {config_file}") + + return not bool(msg), msg, sed_cmds + +def get_field_config_variables(config, index, search_prefixes): + """! Search for variables that are set in the config that correspond to + the fields requested. Some field info items have + synonyms that can be used if the typical name is not set. This is used + in RegridDataPlane wrapper. + + @param config METplusConfig object to search + @param index of field (VAR) to find + @param search_prefixes list of valid prefixes to search for variables + in the config, i.e. FCST_VAR1_ or OBS_GRID_STAT_VAR2_ + @returns dictionary containing a config variable name to be used for + each field info value. If a valid config variable was not set for a + field info value, the value for that key will be set to None. + """ + # list of field info variables to find from config + # used as keys for dictionaries + field_info_items = ['name', + 'levels', + 'thresh', + 'options', + 'output_names', + ] + + field_configs = {} + search_suffixes = {} + + # initialize field configs dictionary values to None + # initialize dictionary of valid suffixes to search for with + # the capitalized version of field info name + for field_info_item in field_info_items: + field_configs[field_info_item] = None + search_suffixes[field_info_item] = [field_info_item.upper()] + + # add alternate suffixes for config variable names to attempt + search_suffixes['name'].append('INPUT_FIELD_NAME') + search_suffixes['name'].append('FIELD_NAME') + search_suffixes['levels'].append('INPUT_LEVEL') + search_suffixes['levels'].append('FIELD_LEVEL') + search_suffixes['output_names'].append('OUTPUT_FIELD_NAME') + search_suffixes['output_names'].append('FIELD_NAME') + + # look through field config keys and obtain highest priority + # variable name for each field config + for search_var, suffixes in search_suffixes.items(): + for prefix in search_prefixes: + + found = False + for suffix in suffixes: + var_name = f"{prefix}VAR{index}_{suffix}" + # if variable is found in config, + # get the value and break out of suffix loop + if config.has_option('config', var_name): + field_configs[search_var] = config.getraw('config', + var_name) + found = True + break + + # if config variable was found, break out of prefix loop + if found: + break + + return field_configs diff --git a/metplus/util/constants.py b/metplus/util/constants.py new file mode 100644 index 0000000000..02cb3e22e5 --- /dev/null +++ b/metplus/util/constants.py @@ -0,0 +1,2 @@ +COMPRESSION_EXTENSIONS = ['.gz', '.bz2', '.zip'] + diff --git a/ci/util/diff_util.py b/metplus/util/diff_util.py similarity index 100% rename from ci/util/diff_util.py rename to metplus/util/diff_util.py diff --git a/metplus/util/met_config.py b/metplus/util/met_config.py new file mode 100644 index 0000000000..2e7c54ad00 --- /dev/null +++ b/metplus/util/met_config.py @@ -0,0 +1,710 @@ +""" +Program Name: met_config.py +Contact(s): George McCabe +""" + +import os + +from .met_util import getlist, get_threshold_via_regex, MISSING_DATA_VALUE +from .met_util import remove_quotes as util_remove_quotes +from .config_metplus import find_indices_in_config_section + +class METConfig: + """! Stores information for a member of a MET config variables that + can be used to set the value, the data type of the item, + optional name of environment variable to set (without METPLUS_ prefix) + if it differs from the name, + and any additional requirements such as remove quotes or make uppercase. + output_dict argument is ignored and only added to allow the argument + to the function that creates an instance of this object. + """ + def __init__(self, name, data_type, + env_var_name=None, + metplus_configs=None, + extra_args=None, + children=None, + output_dict=None): + self.name = name + self.data_type = data_type + self.metplus_configs = metplus_configs + self.extra_args = extra_args + self.env_var_name = env_var_name if env_var_name else name + self.children = children + + def __repr__(self): + return (f'{self.__class__.__name__}({self.name}, {self.data_type}, ' + f'{self.env_var_name}, ' + f'{self.metplus_configs}, ' + f'{self.extra_args}' + f', {self.children}' + ')') + + @property + def name(self): + return self._name + + @name.setter + def name(self, name): + if not isinstance(name, str): + raise TypeError("Name must be a string") + self._name = name + + @property + def data_type(self): + return self._data_type + + @data_type.setter + def data_type(self, data_type): + self._data_type = data_type + + @property + def env_var_name(self): + return self._env_var_name + + @env_var_name.setter + def env_var_name(self, env_var_name): + if not isinstance(env_var_name, str): + raise TypeError("Name must be a string") + self._env_var_name = env_var_name + + @property + def metplus_configs(self): + return self._metplus_configs + + @metplus_configs.setter + def metplus_configs(self, metplus_configs): + # convert to a list if input is a single value + config_names = metplus_configs + if config_names and not isinstance(config_names, list): + config_names = [config_names] + + self._metplus_configs = config_names + + @property + def extra_args(self): + return self._extra_args + + @extra_args.setter + def extra_args(self, extra_args): + args = extra_args if extra_args else {} + if not isinstance(args, dict): + raise TypeError("Expected a dictionary") + + self._extra_args = args + + @property + def children(self): + return self._children + + @children.setter + def children(self, children): + if not children and 'dict' in self.data_type: + raise TypeError("Must have children if data_type is dict.") + + if children: + if 'dict' not in self.data_type: + raise TypeError("data_type must be dict to have " + f"children. data_type is {self.data_type}") + + self._children = children + +def get_wrapped_met_config_file(config, app_name, default_config_file=None): + """! Get the MET config file path for the wrapper from the + METplusConfig object. If unset, use the default value if provided. + + @param default_config_file (optional) filename of wrapped MET config + file found in parm/met_config to use if config file is not set + @returns path to wrapped config file or None if no default is provided + """ + config_name = f'{app_name.upper()}_CONFIG_FILE' + config_file = config.getraw('config', config_name, '') + if config_file: + return config_file + + if not default_config_file: + return None + + default_config_path = os.path.join(config.getdir('PARM_BASE'), + 'met_config', + default_config_file) + config.logger.debug(f"{config_name} is not set. " + f"Using {default_config_path}") + return default_config_path + +def add_met_config_dict(config, app_name, output_dict, dict_name, items): + """! Read config variables for MET config dictionary and set + env_var_dict with formatted values + + @params dict_name name of MET dictionary variable + @params items dictionary where the key is name of variable inside MET + dictionary and the value is info about the item (see parse_item_info + function for more information) + """ + dict_items = [] + + # config prefix i.e GRID_STAT_CLIMO_MEAN_ + metplus_prefix = f'{app_name}_{dict_name}_'.upper() + for name, item_info in items.items(): + data_type, extra, kids, nicknames = _parse_item_info(item_info) + + # config name i.e. GRID_STAT_CLIMO_MEAN_FILE_NAME + metplus_name = f'{metplus_prefix}{name.upper()}' + + # change (n) to _N i.e. distance_map.beta_value(n) + metplus_name = metplus_name.replace('(N)', '_N') + metplus_configs = [] + + if 'dict' not in data_type: + children = None + # handle legacy OBS_WINDOW variables that put OBS_ before app name + # i.e. OBS_GRID_STAT_WINDOW_[BEGIN/END] + if dict_name == 'obs_window': + suffix = 'BEGIN' if name == 'beg' else name.upper() + + metplus_configs.append( + f"OBS_{app_name}_WINDOW_{suffix}".upper() + ) + # also add OBS_WINDOW_[BEGIN/END] + metplus_configs.append(f"OBS_WINDOW_{suffix}") + + # if variable ends with _BEG, read _BEGIN first + if metplus_name.endswith('BEG'): + metplus_configs.append(f'{metplus_name}IN') + + metplus_configs.append(metplus_name) + if nicknames: + for nickname in nicknames: + metplus_configs.append( + f'{app_name}_{nickname}'.upper() + ) + + # if dictionary, read get children from MET config + else: + children = [] + for kid_name, kid_info in kids.items(): + kid_upper = kid_name.upper() + kid_type, kid_extra, _, _ = _parse_item_info(kid_info) + + metplus_configs.append(f'{metplus_name}_{kid_upper}') + metplus_configs.append(f'{metplus_prefix}{kid_upper}') + + kid_args = _parse_extra_args(kid_extra) + child_item = METConfig( + name=kid_name, + data_type=kid_type, + metplus_configs=metplus_configs.copy(), + extra_args=kid_args, + ) + children.append(child_item) + + # reset metplus config list for next kid + metplus_configs.clear() + + # set metplus_configs + metplus_configs = None + + extra_args = _parse_extra_args(extra) + dict_item = ( + METConfig( + name=name, + data_type=data_type, + metplus_configs=metplus_configs, + extra_args=extra_args, + children=children, + ) + ) + dict_items.append(dict_item) + + final_met_config = METConfig( + name=dict_name, + data_type='dict', + children=dict_items, + ) + + return add_met_config_item(config, + final_met_config, + output_dict) + +def add_met_config_item(config, item, output_dict, depth=0): + """! Reads info from METConfig object, gets value from + METplusConfig, and formats it based on the specifications. Sets + value in output dictionary with key starting with METPLUS_. + + @param item METConfig object to read and determine what to get + @param output_dict dictionary to save formatted output + @param depth counter to check if item being processed is nested within + another variable or not. If depth is 0, it is a top level variable. + This is used internally by this function and shouldn't be supplied + outside of calls within this function. + """ + env_var_name = item.env_var_name.upper() + if not env_var_name.startswith('METPLUS_'): + env_var_name = f'METPLUS_{env_var_name}' + + # handle dictionary or dictionary list item + if 'dict' in item.data_type: + tmp_dict = {} + for child in item.children: + if not add_met_config_item(config, child, tmp_dict, + depth=depth+1): + return False + + dict_string = format_met_config(item.data_type, + tmp_dict, + item.name, + keys=None) + + # if handling dict MET config that is not nested inside another + if not depth and item.data_type == 'dict': + env_var_name = f'{env_var_name}_DICT' + + output_dict[env_var_name] = dict_string + return True + + # handle non-dictionary item + set_met_config = set_met_config_function(item.data_type) + if not set_met_config: + return False + + return set_met_config(config, + output_dict, + item.metplus_configs, + item.name, + c_dict_key=env_var_name, + **item.extra_args) + +def add_met_config_dict_list(config, app_name, output_dict, dict_name, + dict_items): + search_string = f'{app_name}_{dict_name}'.upper() + regex = r'^' + search_string + r'(\d+)_(\w+)$' + indices = find_indices_in_config_section(regex, config, + index_index=1, + id_index=2) + + all_met_config_items = {} + is_ok = True + for index, items in indices.items(): + # read all variables for each index + met_config_items = {} + + # check if any variable found doesn't match valid variables + not_in_dict = [item for item in items + if item.lower() not in dict_items] + if any(not_in_dict): + for item in not_in_dict: + config.logger.error("Invalid variable: " + f"{search_string}{index}_{item}") + is_ok = False + continue + + for name, item_info in dict_items.items(): + data_type, extra, kids, nicknames = _parse_item_info(item_info) + metplus_configs = [f'{search_string}{index}_{name.upper()}'] + extra_args = _parse_extra_args(extra) + item = METConfig(name=name, + data_type=data_type, + metplus_configs=metplus_configs, + extra_args=extra_args, + ) + + if not add_met_config_item(config, item, met_config_items): + is_ok = False + continue + + dict_string = format_met_config('dict', + met_config_items, + name='') + all_met_config_items[index] = dict_string + + # format list of dictionaries + output_string = format_met_config('list', + all_met_config_items, + dict_name) + output_dict[f'METPLUS_{dict_name.upper()}_LIST'] = output_string + return is_ok + +def format_met_config(data_type, c_dict, name, keys=None): + """! Return formatted variable named with any if they + are set to a value. If none of the items are set, return empty string + + @param data_type type of value to format + @param c_dict config dictionary to read values from + @param name name of dictionary to create + @param keys list of c_dict keys to use if they are set. If unset (None) + then read all keys from c_dict + @returns MET config formatted dictionary/list + if any items are set, or empty string if not + """ + values = [] + if keys is None: + keys = c_dict.keys() + + for key in keys: + value = c_dict.get(key) + if value: + values.append(str(value)) + + # if none of the keys are set to a value in dict, return empty string + if not values: + return '' + + output = ''.join(values) + # add curly braces if dictionary + if 'dict' in data_type: + output = f"{{{output}}}" + + # add square braces if list + if 'list' in data_type: + output = f"[{output}];" + + # if name is not empty, add variable name and equals sign + if name: + output = f'{name} = {output}' + return output + +def set_met_config_function(item_type): + """! Return function to use based on item type + + @param item_type type of MET config variable to obtain + Valid values: list, string, int, float, thresh, bool + @returns function to use or None if invalid type provided + """ + if item_type == 'int': + return set_met_config_int + elif item_type == 'string': + return set_met_config_string + elif item_type == 'list': + return set_met_config_list + elif item_type == 'float': + return set_met_config_float + elif item_type == 'thresh': + return set_met_config_thresh + elif item_type == 'bool': + return set_met_config_bool + else: + raise ValueError(f"Invalid argument for item type: {item_type}") + +def _get_config_or_default(mp_config_name, get_function, + default=None): + conf_value = '' + + # if no possible METplus config variables are not set + if mp_config_name is None: + # if no default defined, return without doing anything + if not default: + return None + + # if METplus config variable is set, read the value + else: + conf_value = get_function('config', + mp_config_name, + '') + + # if variable is not set and there is a default defined, set default + if not conf_value and default: + conf_value = default + + return conf_value + +def set_met_config_list(config, c_dict, mp_config, met_config_name, + c_dict_key=None, **kwargs): + """! Get list from METplus configuration file and format it to be passed + into a MET configuration file. Set c_dict item with formatted string. + Args: + @param c_dict configuration dictionary to set + @param mp_config_name METplus configuration variable name. Assumed to be + in the [config] section. Value can be a comma-separated list of items. + @param met_config name of MET configuration variable to set. Also used + to determine the key in c_dict to set (upper-case) + @param c_dict_key optional argument to specify c_dict key to store result. If + set to None (default) then use upper-case of met_config_name + @param allow_empty if True, if METplus config variable is set + but is an empty string, then set the c_dict value to an empty + list. If False, behavior is the same as when the variable is + not set at all, which is to not set anything for the c_dict + value + @param remove_quotes if True, output value without quotes. + Default value is False + @param default (Optional) if set, use this value as default + if config is not set + """ + mp_config_name = config.get_mp_config_name(mp_config) + conf_value = _get_config_or_default( + mp_config_name, + get_function=config.getraw, + default=kwargs.get('default') + ) + if conf_value is None: + return True + + # convert value from config to a list + conf_values = getlist(conf_value) + if conf_values or kwargs.get('allow_empty', False): + out_values = [] + for conf_value in conf_values: + remove_quotes = kwargs.get('remove_quotes', False) + # if not removing quotes, escape any quotes found in list items + if not remove_quotes: + conf_value = conf_value.replace('"', '\\"') + + conf_value = util_remove_quotes(conf_value) + if not remove_quotes: + conf_value = f'"{conf_value}"' + + out_values.append(conf_value) + out_value = f"[{', '.join(out_values)}]" + + if not c_dict_key: + c_key = met_config_name.upper() + else: + c_key = c_dict_key + + if met_config_name: + out_value = f'{met_config_name} = {out_value};' + c_dict[c_key] = out_value + + return True + +def set_met_config_string(config, c_dict, mp_config, met_config_name, + c_dict_key=None, **kwargs): + """! Get string from METplus configuration file and format it to be passed + into a MET configuration file. Set c_dict item with formatted string. + + @param c_dict configuration dictionary to set + @param mp_config METplus configuration variable name. Assumed to be + in the [config] section. Value can be a comma-separated list of items. + @param met_config_name name of MET configuration variable to set. Also used + to determine the key in c_dict to set (upper-case) + @param c_dict_key optional argument to specify c_dict key to store result. If + set to None (default) then use upper-case of met_config_name + @param remove_quotes if True, output value without quotes. + Default value is False + @param to_grid if True, format to_grid value + Default value is False + @param default (Optional) if set, use this value as default + if config is not set + """ + mp_config_name = config.get_mp_config_name(mp_config) + conf_value = _get_config_or_default( + mp_config_name, + get_function=config.getraw, + default=kwargs.get('default') + ) + if not conf_value: + return True + + conf_value = util_remove_quotes(conf_value) + # add quotes back if remote quotes is False + if not kwargs.get('remove_quotes'): + conf_value = f'"{conf_value}"' + + if kwargs.get('uppercase', False): + conf_value = conf_value.upper() + + if kwargs.get('to_grid', False): + conf_value = format_regrid_to_grid(conf_value) + + c_key = c_dict_key if c_dict_key else met_config_name.upper() + if met_config_name: + conf_value = f'{met_config_name} = {conf_value};' + + c_dict[c_key] = conf_value + return True + +def set_met_config_number(config, c_dict, num_type, mp_config, + met_config_name, c_dict_key=None, **kwargs): + """! Get integer from METplus configuration file and format it to be passed + into a MET configuration file. Set c_dict item with formatted string. + Args: + @param c_dict configuration dictionary to set + @param num_type type of number to get from config. If set to 'int', call + getint function. If not, call getfloat function. + @param mp_config METplus configuration variable name. Assumed to be + in the [config] section. Value can be a comma-separated list of items. + @param met_config_name name of MET configuration variable to set. Also used + to determine the key in c_dict to set (upper-case) if c_dict_key is None + @param c_dict_key optional argument to specify c_dict key to store result. If + set to None (default) then use upper-case of met_config_name + @param default (Optional) if set, use this value as default + if config is not set + """ + mp_config_name = config.get_mp_config_name(mp_config) + if mp_config_name is None: + return True + + if num_type == 'int': + conf_value = config.getint('config', mp_config_name) + else: + conf_value = config.getfloat('config', mp_config_name) + + if conf_value is None: + return False + if conf_value != MISSING_DATA_VALUE: + if not c_dict_key: + c_key = met_config_name.upper() + else: + c_key = c_dict_key + + if met_config_name: + out_value = f"{met_config_name} = {str(conf_value)};" + else: + out_value = str(conf_value) + c_dict[c_key] = out_value + + return True + +def set_met_config_int(config, c_dict, mp_config_name, met_config_name, + c_dict_key=None, **kwargs): + return set_met_config_number(config, c_dict, 'int', + mp_config_name, + met_config_name, + c_dict_key=c_dict_key, + **kwargs) + +def set_met_config_float(config, c_dict, mp_config_name, + met_config_name, c_dict_key=None, **kwargs): + return set_met_config_number(config, c_dict, 'float', + mp_config_name, + met_config_name, + c_dict_key=c_dict_key, + **kwargs) + +def set_met_config_thresh(config, c_dict, mp_config, met_config_name, + c_dict_key=None, **kwargs): + mp_config_name = config.get_mp_config_name(mp_config) + if mp_config_name is None: + return True + + conf_value = config.getstr('config', mp_config_name, '') + if conf_value: + if get_threshold_via_regex(conf_value) is None: + config.logger.error(f"Incorrectly formatted threshold: {mp_config_name}") + return False + + if not c_dict_key: + c_key = met_config_name.upper() + else: + c_key = c_dict_key + + if met_config_name: + out_value = f"{met_config_name} = {str(conf_value)};" + else: + out_value = str(conf_value) + + c_dict[c_key] = out_value + return True + +def set_met_config_bool(config, c_dict, mp_config, met_config_name, + c_dict_key=None, **kwargs): + """! Get boolean from METplus configuration file and format it to be + passed into a MET configuration file. Set c_dict item with boolean + value expressed as a string. + Args: + @param c_dict configuration dictionary to set + @param mp_config METplus configuration variable name. + Assumed to be in the [config] section. + @param met_config_name name of MET configuration variable to + set. Also used to determine the key in c_dict to set + (upper-case) + @param c_dict_key optional argument to specify c_dict key to + store result. If set to None (default) then use upper-case of + met_config_name + @param uppercase If true, set value to TRUE or FALSE + """ + mp_config_name = config.get_mp_config_name(mp_config) + if mp_config_name is None: + return True + conf_value = config.getbool('config', mp_config_name, '') + if conf_value is None: + config.logger.error(f'Invalid boolean value set for {mp_config_name}') + return False + + # if not invalid but unset, return without setting c_dict with no error + if conf_value == '': + return True + + conf_value = str(conf_value) + if kwargs.get('uppercase', True): + conf_value = conf_value.upper() + + if not c_dict_key: + c_key = met_config_name.upper() + else: + c_key = c_dict_key + + conf_value = util_remove_quotes(conf_value) + if met_config_name: + conf_value = f'{met_config_name} = {conf_value};' + c_dict[c_key] = conf_value + return True + +def format_regrid_to_grid(to_grid): + to_grid = to_grid.strip('"') + if not to_grid: + to_grid = 'NONE' + + # if NONE, FCST, or OBS force uppercase, otherwise add quotes + if to_grid.upper() in ['NONE', 'FCST', 'OBS']: + to_grid = to_grid.upper() + else: + to_grid = f'"{to_grid}"' + + return to_grid + +def _parse_item_info(item_info): + """! Parses info about a MET config dictionary item. The input can + be a single string that is the data type of the item. It can also be + a tuple containing 2 to 4 values. The additional values must be + supplied in order: + * extra: string of extra information about item, i.e. + 'remove_quotes', 'uppercase', or 'allow_empty' + * kids: dictionary describing child values (used only for dict items) + where the key is the name of the variable and the value is item info + for the child variable in the same format as item_info that is + parsed in this function + * nicknames: list of other METplus config variable name that can be + used to set a value. The app name i.e. GRID_STAT_ is prepended to + each nickname in the list. Used for backwards compatibility for + METplus config variables whose name does not match the MET config + variable name + + @param item_info string or tuple containing information about a + dictionary item + @returns tuple of data type, extra info, children, and nicknames or + None for each tuple value that is not set + """ + if isinstance(item_info, tuple): + data_type, *rest = item_info + else: + data_type = item_info + rest = [] + + extra = rest.pop(0) if rest else None + kids = rest.pop(0) if rest else None + nicknames = rest.pop(0) if rest else None + + return data_type, extra, kids, nicknames + +def _parse_extra_args(extra): + """! Check string for extra option keywords and set them to True in + dictionary if they are found. Supports 'remove_quotes', 'uppercase' + and 'allow_empty' + + @param extra string to parse for keywords + @returns dictionary with extra args set if found in string + """ + extra_args = {} + if not extra: + return extra_args + + VALID_EXTRAS = ( + 'remove_quotes', + 'uppercase', + 'allow_empty', + 'to_grid', + 'default', + ) + for extra_option in VALID_EXTRAS: + if extra_option in extra: + extra_args[extra_option] = True + return extra_args diff --git a/metplus/util/met_dictionary_info.py b/metplus/util/met_dictionary_info.py deleted file mode 100644 index 2decdc0f22..0000000000 --- a/metplus/util/met_dictionary_info.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Program Name: met_dictionary_info.py -Contact(s): George McCabe -Abstract: -History Log: Initial version -Usage: -Parameters: None -Input Files: N/A -Output Files: N/A -""" - - -class METConfigInfo: - """! Stores information for a member of a MET config variables that - can be used to set the value, the data type of the item, - optional name of environment variable to set (without METPLUS_ prefix) - if it differs from the name, - and any additional requirements such as remove quotes or make uppercase. - output_dict argument is ignored and only added to allow the argument - to the function that creates an instance of this object. - """ - def __init__(self, name, data_type, - env_var_name=None, - metplus_configs=None, - extra_args=None, - children=None, - output_dict=None): - self.name = name - self.data_type = data_type - self.metplus_configs = metplus_configs - self.extra_args = extra_args - self.env_var_name = env_var_name if env_var_name else name - self.children = children - - def __repr__(self): - return (f'{self.__class__.__name__}({self.name}, {self.data_type}, ' - f'{self.env_var_name}, ' - f'{self.metplus_configs}, ' - f'{self.extra_args}' - f', {self.children}' - ')') - - @property - def name(self): - return self._name - - @name.setter - def name(self, name): - if not isinstance(name, str): - raise TypeError("Name must be a string") - self._name = name - - @property - def data_type(self): - return self._data_type - - @data_type.setter - def data_type(self, data_type): - self._data_type = data_type - - @property - def env_var_name(self): - return self._env_var_name - - @env_var_name.setter - def env_var_name(self, env_var_name): - if not isinstance(env_var_name, str): - raise TypeError("Name must be a string") - self._env_var_name = env_var_name - - @property - def metplus_configs(self): - return self._metplus_configs - - @metplus_configs.setter - def metplus_configs(self, metplus_configs): - # convert to a list if input is a single value - config_names = metplus_configs - if config_names and not isinstance(config_names, list): - config_names = [config_names] - - self._metplus_configs = config_names - - @property - def extra_args(self): - return self._extra_args - - @extra_args.setter - def extra_args(self, extra_args): - args = extra_args if extra_args else {} - if not isinstance(args, dict): - raise TypeError("Expected a dictionary") - - self._extra_args = args - - @property - def children(self): - return self._children - - @children.setter - def children(self, children): - if not children and 'dict' in self.data_type: - raise TypeError("Must have children if data_type is dict.") - - if children: - if 'dict' not in self.data_type: - raise TypeError("data_type must be dict to have " - f"children. data_type is {self.data_type}") - - self._children = children diff --git a/metplus/util/met_util.py b/metplus/util/met_util.py index 105740e420..5b853807fd 100644 --- a/metplus/util/met_util.py +++ b/metplus/util/met_util.py @@ -1,42 +1,27 @@ -import logging import os import shutil import sys import datetime -import errno -import time -import calendar import re import gzip import bz2 import zipfile import struct -import getpass -from os import stat -from pwd import getpwuid from csv import reader -from os.path import dirname, realpath from dateutil.relativedelta import relativedelta from pathlib import Path from importlib import import_module -import produtil.setup -import produtil.log - from .string_template_substitution import do_string_sub from .string_template_substitution import parse_template -from .string_template_substitution import get_tags from . import time_util as time_util -from .doc_util import get_wrapper_name - from .. import get_metplus_version """!@namespace met_util @brief Provides Utility functions for METplus. """ -# list of compression extensions that are handled by METplus -VALID_EXTENSIONS = ['.gz', '.bz2', '.zip'] +from .constants import * PYTHON_EMBEDDING_TYPES = ['PYTHON_NUMPY', 'PYTHON_XARRAY', 'PYTHON_PANDAS'] @@ -75,7 +60,7 @@ def pre_run_setup(config_inputs): logger.info(f"Config Input: {config_item}") # validate configuration variables - isOK_A, isOK_B, isOK_C, isOK_D, all_sed_cmds = validate_configuration_variables(config) + isOK_A, isOK_B, isOK_C, isOK_D, all_sed_cmds = config_metplus.validate_configuration_variables(config) if not (isOK_A and isOK_B and isOK_C and isOK_D): # if any sed commands were generated, write them to the sed file if all_sed_cmds: @@ -158,7 +143,7 @@ def run_metplus(config, process_list): elif loop_order == "times": all_commands = loop_over_times_and_call(config, processes) else: - logger.error("Invalid LOOP_ORDER defined. " + \ + logger.error("Invalid LOOP_ORDER defined. " "Options are processes, times") return 1 @@ -238,493 +223,6 @@ def write_all_commands(all_commands, config): file_handle.write("COMMAND:\n") file_handle.write(f"{command}\n\n") -def check_for_deprecated_config(config): - """!Checks user configuration files and reports errors or warnings if any deprecated variable - is found. If an alternate variable name can be suggested, add it to the 'alt' section - If the alternate cannot be literally substituted for the old name, set copy to False - Args: - @config : METplusConfig object to evaluate - Returns: - A tuple containing a boolean if the configuration is suitable to run or not and - if it is not correct, the 2nd item is a list of sed commands that can be run to help - fix the incorrect configuration variables - """ - - # key is the name of the depreacted variable that is no longer allowed in any config files - # value is a dictionary containing information about what to do with the deprecated config - # 'sec' is the section of the config file where the replacement resides, i.e. config, dir, - # filename_templates - # 'alt' is the alternative name for the deprecated config. this can be a single variable name or - # text to describe multiple variables or how to handle it. Set to None to tell the user to - # just remove the variable - # 'copy' is an optional item (defaults to True). set this to False if one cannot simply replace - # the deprecated config variable name with the value in 'alt' - # 'req' is an optional item (defaults to True). this to False to report a warning for the - # deprecated config and allow execution to continue. this is generally no longer used - # because we are requiring users to update the config files. if used, the developer must - # modify the code to handle both variables accordingly - deprecated_dict = { - 'LOOP_BY_INIT' : {'sec' : 'config', 'alt' : 'LOOP_BY', 'copy': False}, - 'LOOP_METHOD' : {'sec' : 'config', 'alt' : 'LOOP_ORDER'}, - 'PREPBUFR_DIR_REGEX' : {'sec' : 'regex_pattern', 'alt' : None}, - 'PREPBUFR_FILE_REGEX' : {'sec' : 'regex_pattern', 'alt' : None}, - 'OBS_INPUT_DIR_REGEX' : {'sec' : 'regex_pattern', 'alt' : 'OBS_POINT_STAT_INPUT_DIR', 'copy': False}, - 'FCST_INPUT_DIR_REGEX' : {'sec' : 'regex_pattern', 'alt' : 'FCST_POINT_STAT_INPUT_DIR', 'copy': False}, - 'FCST_INPUT_FILE_REGEX' : - {'sec' : 'regex_pattern', 'alt' : 'FCST_POINT_STAT_INPUT_TEMPLATE', 'copy': False}, - 'OBS_INPUT_FILE_REGEX' : {'sec' : 'regex_pattern', 'alt' : 'OBS_POINT_STAT_INPUT_TEMPLATE', 'copy': False}, - 'PREPBUFR_DATA_DIR' : {'sec' : 'dir', 'alt' : 'PB2NC_INPUT_DIR'}, - 'PREPBUFR_MODEL_DIR_NAME' : {'sec' : 'dir', 'alt' : 'PB2NC_INPUT_DIR', 'copy': False}, - 'OBS_INPUT_FILE_TMPL' : - {'sec' : 'filename_templates', 'alt' : 'OBS_POINT_STAT_INPUT_TEMPLATE'}, - 'FCST_INPUT_FILE_TMPL' : - {'sec' : 'filename_templates', 'alt' : 'FCST_POINT_STAT_INPUT_TEMPLATE'}, - 'NC_FILE_TMPL' : {'sec' : 'filename_templates', 'alt' : 'PB2NC_OUTPUT_TEMPLATE'}, - 'FCST_INPUT_DIR' : {'sec' : 'dir', 'alt' : 'FCST_POINT_STAT_INPUT_DIR'}, - 'OBS_INPUT_DIR' : {'sec' : 'dir', 'alt' : 'OBS_POINT_STAT_INPUT_DIR'}, - 'REGRID_TO_GRID' : {'sec' : 'config', 'alt' : 'POINT_STAT_REGRID_TO_GRID'}, - 'FCST_HR_START' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, - 'FCST_HR_END' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, - 'FCST_HR_INTERVAL' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, - 'START_DATE' : {'sec' : 'config', 'alt' : 'INIT_BEG or VALID_BEG', 'copy': False}, - 'END_DATE' : {'sec' : 'config', 'alt' : 'INIT_END or VALID_END', 'copy': False}, - 'INTERVAL_TIME' : {'sec' : 'config', 'alt' : 'INIT_INCREMENT or VALID_INCREMENT', 'copy': False}, - 'BEG_TIME' : {'sec' : 'config', 'alt' : 'INIT_BEG or VALID_BEG', 'copy': False}, - 'END_TIME' : {'sec' : 'config', 'alt' : 'INIT_END or VALID_END', 'copy': False}, - 'START_HOUR' : {'sec' : 'config', 'alt' : 'INIT_BEG or VALID_BEG', 'copy': False}, - 'END_HOUR' : {'sec' : 'config', 'alt' : 'INIT_END or VALID_END', 'copy': False}, - 'OBS_BUFR_VAR_LIST' : {'sec' : 'config', 'alt' : 'PB2NC_OBS_BUFR_VAR_LIST'}, - 'TIME_SUMMARY_FLAG' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_FLAG'}, - 'TIME_SUMMARY_BEG' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_BEG'}, - 'TIME_SUMMARY_END' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_END'}, - 'TIME_SUMMARY_VAR_NAMES' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_VAR_NAMES'}, - 'TIME_SUMMARY_TYPE' : {'sec' : 'config', 'alt' : 'PB2NC_TIME_SUMMARY_TYPE'}, - 'OVERWRITE_NC_OUTPUT' : {'sec' : 'config', 'alt' : 'PB2NC_SKIP_IF_OUTPUT_EXISTS', 'copy': False}, - 'VERTICAL_LOCATION' : {'sec' : 'config', 'alt' : 'PB2NC_VERTICAL_LOCATION'}, - 'VERIFICATION_GRID' : {'sec' : 'config', 'alt' : 'REGRID_DATA_PLANE_VERIF_GRID'}, - 'WINDOW_RANGE_BEG' : {'sec' : 'config', 'alt' : 'OBS_WINDOW_BEGIN'}, - 'WINDOW_RANGE_END' : {'sec' : 'config', 'alt' : 'OBS_WINDOW_END'}, - 'OBS_EXACT_VALID_TIME' : - {'sec' : 'config', 'alt' : 'OBS_WINDOW_BEGIN and OBS_WINDOW_END', 'copy': False}, - 'FCST_EXACT_VALID_TIME' : - {'sec' : 'config', 'alt' : 'FCST_WINDOW_BEGIN and FCST_WINDOW_END', 'copy': False}, - 'PCP_COMBINE_METHOD' : - {'sec' : 'config', 'alt' : 'FCST_PCP_COMBINE_METHOD and/or OBS_PCP_COMBINE_METHOD', 'copy': False}, - 'FHR_BEG' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, - 'FHR_END' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, - 'FHR_INC' : {'sec' : 'config', 'alt' : 'LEAD_SEQ', 'copy': False}, - 'FHR_GROUP_BEG' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_[N]', 'copy': False}, - 'FHR_GROUP_END' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_[N]', 'copy': False}, - 'FHR_GROUP_LABELS' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_[N]_LABEL', 'copy': False}, - 'CYCLONE_OUT_DIR' : {'sec' : 'dir', 'alt' : 'CYCLONE_OUTPUT_DIR'}, - 'ENSEMBLE_STAT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'ENSEMBLE_STAT_OUTPUT_DIR'}, - 'EXTRACT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'EXTRACT_TILES_OUTPUT_DIR'}, - 'GRID_STAT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'GRID_STAT_OUTPUT_DIR'}, - 'MODE_OUT_DIR' : {'sec' : 'dir', 'alt' : 'MODE_OUTPUT_DIR'}, - 'MTD_OUT_DIR' : {'sec' : 'dir', 'alt' : 'MTD_OUTPUT_DIR'}, - 'SERIES_INIT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_OUTPUT_DIR'}, - 'SERIES_LEAD_OUT_DIR' : {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_OUTPUT_DIR'}, - 'SERIES_INIT_FILTERED_OUT_DIR' : - {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, - 'SERIES_LEAD_FILTERED_OUT_DIR' : - {'sec' : 'dir', 'alt' : 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, - 'STAT_ANALYSIS_OUT_DIR' : - {'sec' : 'dir', 'alt' : 'STAT_ANALYSIS_OUTPUT_DIR'}, - 'TCMPR_PLOT_OUT_DIR' : {'sec' : 'dir', 'alt' : 'TCMPR_PLOT_OUTPUT_DIR'}, - 'FCST_MIN_FORECAST' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_MIN'}, - 'FCST_MAX_FORECAST' : {'sec' : 'config', 'alt' : 'LEAD_SEQ_MAX'}, - 'OBS_MIN_FORECAST' : {'sec' : 'config', 'alt' : 'OBS_PCP_COMBINE_MIN_LEAD'}, - 'OBS_MAX_FORECAST' : {'sec' : 'config', 'alt' : 'OBS_PCP_COMBINE_MAX_LEAD'}, - 'FCST_INIT_INTERVAL' : {'sec' : 'config', 'alt' : None}, - 'OBS_INIT_INTERVAL' : {'sec' : 'config', 'alt' : None}, - 'FCST_DATA_INTERVAL' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_DATA_INTERVAL'}, - 'OBS_DATA_INTERVAL' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_DATA_INTERVAL'}, - 'FCST_IS_DAILY_FILE' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_IS_DAILY_FILE'}, - 'OBS_IS_DAILY_FILE' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_IS_DAILY_FILE'}, - 'FCST_TIMES_PER_FILE' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_TIMES_PER_FILE'}, - 'OBS_TIMES_PER_FILE' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_TIMES_PER_FILE'}, - 'FCST_LEVEL' : {'sec' : '', 'alt' : 'FCST_PCP_COMBINE_INPUT_ACCUMS', 'copy': False}, - 'OBS_LEVEL' : {'sec' : '', 'alt' : 'OBS_PCP_COMBINE_INPUT_ACCUMS', 'copy': False}, - 'MODE_FCST_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'FCST_MODE_CONV_RADIUS'}, - 'MODE_FCST_CONV_THRESH' : {'sec' : 'config', 'alt' : 'FCST_MODE_CONV_THRESH'}, - 'MODE_FCST_MERGE_FLAG' : {'sec' : 'config', 'alt' : 'FCST_MODE_MERGE_FLAG'}, - 'MODE_FCST_MERGE_THRESH' : {'sec' : 'config', 'alt' : 'FCST_MODE_MERGE_THRESH'}, - 'MODE_OBS_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'OBS_MODE_CONV_RADIUS'}, - 'MODE_OBS_CONV_THRESH' : {'sec' : 'config', 'alt' : 'OBS_MODE_CONV_THRESH'}, - 'MODE_OBS_MERGE_FLAG' : {'sec' : 'config', 'alt' : 'OBS_MODE_MERGE_FLAG'}, - 'MODE_OBS_MERGE_THRESH' : {'sec' : 'config', 'alt' : 'OBS_MODE_MERGE_THRESH'}, - 'MTD_FCST_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'FCST_MTD_CONV_RADIUS'}, - 'MTD_FCST_CONV_THRESH' : {'sec' : 'config', 'alt' : 'FCST_MTD_CONV_THRESH'}, - 'MTD_OBS_CONV_RADIUS' : {'sec' : 'config', 'alt' : 'OBS_MTD_CONV_RADIUS'}, - 'MTD_OBS_CONV_THRESH' : {'sec' : 'config', 'alt' : 'OBS_MTD_CONV_THRESH'}, - 'RM_EXE' : {'sec' : 'exe', 'alt' : 'RM'}, - 'CUT_EXE' : {'sec' : 'exe', 'alt' : 'CUT'}, - 'TR_EXE' : {'sec' : 'exe', 'alt' : 'TR'}, - 'NCAP2_EXE' : {'sec' : 'exe', 'alt' : 'NCAP2'}, - 'CONVERT_EXE' : {'sec' : 'exe', 'alt' : 'CONVERT'}, - 'NCDUMP_EXE' : {'sec' : 'exe', 'alt' : 'NCDUMP'}, - 'EGREP_EXE' : {'sec' : 'exe', 'alt' : 'EGREP'}, - 'ADECK_TRACK_DATA_DIR' : {'sec' : 'dir', 'alt' : 'TC_PAIRS_ADECK_INPUT_DIR'}, - 'BDECK_TRACK_DATA_DIR' : {'sec' : 'dir', 'alt' : 'TC_PAIRS_BDECK_INPUT_DIR'}, - 'MISSING_VAL_TO_REPLACE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_MISSING_VAL_TO_REPLACE'}, - 'MISSING_VAL' : {'sec' : 'config', 'alt' : 'TC_PAIRS_MISSING_VAL'}, - 'TRACK_DATA_SUBDIR_MOD' : {'sec' : 'dir', 'alt' : None}, - 'ADECK_FILE_PREFIX' : {'sec' : 'config', 'alt' : 'TC_PAIRS_ADECK_TEMPLATE', 'copy': False}, - 'BDECK_FILE_PREFIX' : {'sec' : 'config', 'alt' : 'TC_PAIRS_BDECK_TEMPLATE', 'copy': False}, - 'TOP_LEVEL_DIRS' : {'sec' : 'config', 'alt' : 'TC_PAIRS_READ_ALL_FILES'}, - 'TC_PAIRS_DIR' : {'sec' : 'dir', 'alt' : 'TC_PAIRS_OUTPUT_DIR'}, - 'CYCLONE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_CYCLONE'}, - 'STORM_ID' : {'sec' : 'config', 'alt' : 'TC_PAIRS_STORM_ID'}, - 'BASIN' : {'sec' : 'config', 'alt' : 'TC_PAIRS_BASIN'}, - 'STORM_NAME' : {'sec' : 'config', 'alt' : 'TC_PAIRS_STORM_NAME'}, - 'DLAND_FILE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_DLAND_FILE'}, - 'TRACK_TYPE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_REFORMAT_DECK'}, - 'FORECAST_TMPL' : {'sec' : 'filename_templates', 'alt' : 'TC_PAIRS_ADECK_TEMPLATE'}, - 'REFERENCE_TMPL' : {'sec' : 'filename_templates', 'alt' : 'TC_PAIRS_BDECK_TEMPLATE'}, - 'TRACK_DATA_MOD_FORCE_OVERWRITE' : - {'sec' : 'config', 'alt' : 'TC_PAIRS_SKIP_IF_REFORMAT_EXISTS', 'copy': False}, - 'TC_PAIRS_FORCE_OVERWRITE' : {'sec' : 'config', 'alt' : 'TC_PAIRS_SKIP_IF_OUTPUT_EXISTS', 'copy': False}, - 'GRID_STAT_CONFIG' : {'sec' : 'config', 'alt' : 'GRID_STAT_CONFIG_FILE'}, - 'MODE_CONFIG' : {'sec' : 'config', 'alt': 'MODE_CONFIG_FILE'}, - 'FCST_PCP_COMBINE_INPUT_LEVEL': {'sec': 'config', 'alt' : 'FCST_PCP_COMBINE_INPUT_ACCUMS'}, - 'OBS_PCP_COMBINE_INPUT_LEVEL': {'sec': 'config', 'alt' : 'OBS_PCP_COMBINE_INPUT_ACCUMS'}, - 'TIME_METHOD': {'sec': 'config', 'alt': 'LOOP_BY', 'copy': False}, - 'MODEL_DATA_DIR': {'sec': 'dir', 'alt': 'EXTRACT_TILES_GRID_INPUT_DIR'}, - 'STAT_LIST': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_STAT_LIST'}, - 'NLAT': {'sec': 'config', 'alt': 'EXTRACT_TILES_NLAT'}, - 'NLON': {'sec': 'config', 'alt': 'EXTRACT_TILES_NLON'}, - 'DLAT': {'sec': 'config', 'alt': 'EXTRACT_TILES_DLAT'}, - 'DLON': {'sec': 'config', 'alt': 'EXTRACT_TILES_DLON'}, - 'LON_ADJ': {'sec': 'config', 'alt': 'EXTRACT_TILES_LON_ADJ'}, - 'LAT_ADJ': {'sec': 'config', 'alt': 'EXTRACT_TILES_LAT_ADJ'}, - 'OVERWRITE_TRACK': {'sec': 'config', 'alt': 'EXTRACT_TILES_OVERWRITE_TRACK'}, - 'BACKGROUND_MAP': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_BACKGROUND_MAP'}, - 'GFS_FCST_FILE_TMPL': {'sec': 'filename_templates', 'alt': 'FCST_EXTRACT_TILES_INPUT_TEMPLATE'}, - 'GFS_ANLY_FILE_TMPL': {'sec': 'filename_templates', 'alt': 'OBS_EXTRACT_TILES_INPUT_TEMPLATE'}, - 'SERIES_BY_LEAD_FILTERED_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, - 'SERIES_BY_INIT_FILTERED_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_FILTERED_OUTPUT_DIR'}, - 'SERIES_BY_LEAD_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_OUTPUT_DIR'}, - 'SERIES_BY_INIT_OUTPUT_DIR': {'sec': 'dir', 'alt': 'SERIES_ANALYSIS_OUTPUT_DIR'}, - 'SERIES_BY_LEAD_GROUP_FCSTS': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_GROUP_FCSTS'}, - 'SERIES_ANALYSIS_BY_LEAD_CONFIG_FILE': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_CONFIG_FILE'}, - 'SERIES_ANALYSIS_BY_INIT_CONFIG_FILE': {'sec': 'config', 'alt': 'SERIES_ANALYSIS_CONFIG_FILE'}, - 'ENSEMBLE_STAT_MET_OBS_ERROR_TABLE': {'sec': 'config', 'alt': 'ENSEMBLE_STAT_MET_OBS_ERR_TABLE'}, - 'VAR_LIST': {'sec': 'config', 'alt': 'BOTH_VAR_NAME BOTH_VAR_LEVELS or SERIES_ANALYSIS_VAR_LIST', 'copy': False}, - 'SERIES_ANALYSIS_VAR_LIST': {'sec': 'config', 'alt': 'BOTH_VAR_NAME BOTH_VAR_LEVELS', 'copy': False}, - 'EXTRACT_TILES_VAR_LIST': {'sec': 'config', 'alt': ''}, - 'STAT_ANALYSIS_LOOKIN_DIR': {'sec': 'dir', 'alt': 'MODEL1_STAT_ANALYSIS_LOOKIN_DIR'}, - 'VALID_HOUR_METHOD': {'sec': 'config', 'alt': None}, - 'VALID_HOUR_BEG': {'sec': 'config', 'alt': None}, - 'VALID_HOUR_END': {'sec': 'config', 'alt': None}, - 'VALID_HOUR_INCREMENT': {'sec': 'config', 'alt': None}, - 'INIT_HOUR_METHOD': {'sec': 'config', 'alt': None}, - 'INIT_HOUR_BEG': {'sec': 'config', 'alt': None}, - 'INIT_HOUR_END': {'sec': 'config', 'alt': None}, - 'INIT_HOUR_INCREMENT': {'sec': 'config', 'alt': None}, - 'STAT_ANALYSIS_CONFIG': {'sec': 'config', 'alt': 'STAT_ANALYSIS_CONFIG_FILE'}, - 'JOB_NAME': {'sec': 'config', 'alt': 'STAT_ANALYSIS_JOB_NAME'}, - 'JOB_ARGS': {'sec': 'config', 'alt': 'STAT_ANALYSIS_JOB_ARGS'}, - 'FCST_LEAD': {'sec': 'config', 'alt': 'FCST_LEAD_LIST'}, - 'FCST_VAR_NAME': {'sec': 'config', 'alt': 'FCST_VAR_LIST'}, - 'FCST_VAR_LEVEL': {'sec': 'config', 'alt': 'FCST_VAR_LEVEL_LIST'}, - 'OBS_VAR_NAME': {'sec': 'config', 'alt': 'OBS_VAR_LIST'}, - 'OBS_VAR_LEVEL': {'sec': 'config', 'alt': 'OBS_VAR_LEVEL_LIST'}, - 'REGION': {'sec': 'config', 'alt': 'VX_MASK_LIST'}, - 'INTERP': {'sec': 'config', 'alt': 'INTERP_LIST'}, - 'INTERP_PTS': {'sec': 'config', 'alt': 'INTERP_PTS_LIST'}, - 'CONV_THRESH': {'sec': 'config', 'alt': 'CONV_THRESH_LIST'}, - 'FCST_THRESH': {'sec': 'config', 'alt': 'FCST_THRESH_LIST'}, - 'LINE_TYPE': {'sec': 'config', 'alt': 'LINE_TYPE_LIST'}, - 'STAT_ANALYSIS_DUMP_ROW_TMPL': {'sec': 'filename_templates', 'alt': 'STAT_ANALYSIS_DUMP_ROW_TEMPLATE'}, - 'STAT_ANALYSIS_OUT_STAT_TMPL': {'sec': 'filename_templates', 'alt': 'STAT_ANALYSIS_OUT_STAT_TEMPLATE'}, - 'PLOTTING_SCRIPTS_DIR': {'sec': 'dir', 'alt': 'MAKE_PLOTS_SCRIPTS_DIR'}, - 'STAT_FILES_INPUT_DIR': {'sec': 'dir', 'alt': 'MAKE_PLOTS_INPUT_DIR'}, - 'PLOTTING_OUTPUT_DIR': {'sec': 'dir', 'alt': 'MAKE_PLOTS_OUTPUT_DIR'}, - 'VERIF_CASE': {'sec': 'config', 'alt': 'MAKE_PLOTS_VERIF_CASE'}, - 'VERIF_TYPE': {'sec': 'config', 'alt': 'MAKE_PLOTS_VERIF_TYPE'}, - 'PLOT_TIME': {'sec': 'config', 'alt': 'DATE_TIME'}, - 'MODEL_NAME': {'sec': 'config', 'alt': 'MODEL'}, - 'MODEL_OBS_NAME': {'sec': 'config', 'alt': 'MODEL_OBTYPE'}, - 'MODEL_STAT_DIR': {'sec': 'dir', 'alt': 'MODEL_STAT_ANALYSIS_LOOKIN_DIR'}, - 'MODEL_NAME_ON_PLOT': {'sec': 'config', 'alt': 'MODEL_REFERENCE_NAME'}, - 'REGION_LIST': {'sec': 'config', 'alt': 'VX_MASK_LIST'}, - 'PLOT_STATS_LIST': {'sec': 'config', 'alt': 'MAKE_PLOT_STATS_LIST'}, - 'CI_METHOD': {'sec': 'config', 'alt': 'MAKE_PLOTS_CI_METHOD'}, - 'VERIF_GRID': {'sec': 'config', 'alt': 'MAKE_PLOTS_VERIF_GRID'}, - 'EVENT_EQUALIZATION': {'sec': 'config', 'alt': 'MAKE_PLOTS_EVENT_EQUALIZATION'}, - 'MTD_CONFIG': {'sec': 'config', 'alt': 'MTD_CONFIG_FILE'}, - 'CLIMO_GRID_STAT_INPUT_DIR': {'sec': 'dir', 'alt': 'GRID_STAT_CLIMO_MEAN_INPUT_DIR'}, - 'CLIMO_GRID_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', 'alt': 'GRID_STAT_CLIMO_MEAN_INPUT_TEMPLATE'}, - 'CLIMO_POINT_STAT_INPUT_DIR': {'sec': 'dir', 'alt': 'POINT_STAT_CLIMO_MEAN_INPUT_DIR'}, - 'CLIMO_POINT_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', 'alt': 'POINT_STAT_CLIMO_MEAN_INPUT_TEMPLATE'}, - 'GEMPAKTOCF_CLASSPATH': {'sec': 'exe', 'alt': 'GEMPAKTOCF_JAR', 'copy': False}, - 'CUSTOM_INGEST__OUTPUT_DIR': {'sec': 'dir', 'alt': 'PY_EMBED_INGEST__OUTPUT_DIR'}, - 'CUSTOM_INGEST__OUTPUT_TEMPLATE': {'sec': 'filename_templates', 'alt': 'PY_EMBED_INGEST__OUTPUT_TEMPLATE'}, - 'CUSTOM_INGEST__OUTPUT_GRID': {'sec': 'config', 'alt': 'PY_EMBED_INGEST__OUTPUT_GRID'}, - 'CUSTOM_INGEST__SCRIPT': {'sec': 'config', 'alt': 'PY_EMBED_INGEST__SCRIPT'}, - 'CUSTOM_INGEST__TYPE': {'sec': 'config', 'alt': 'PY_EMBED_INGEST__TYPE'}, - 'TC_STAT_RUN_VIA': {'sec': 'config', 'alt': 'TC_STAT_CONFIG_FILE', - 'copy': False}, - 'TC_STAT_CMD_LINE_JOB': {'sec': 'config', 'alt': 'TC_STAT_JOB_ARGS'}, - 'TC_STAT_JOBS_LIST': {'sec': 'config', 'alt': 'TC_STAT_JOB_ARGS'}, - 'EXTRACT_TILES_OVERWRITE_TRACK': {'sec': 'config', - 'alt': 'EXTRACT_TILES_SKIP_IF_OUTPUT_EXISTS', - 'copy': False}, - 'EXTRACT_TILES_PAIRS_INPUT_DIR': {'sec': 'dir', - 'alt': 'EXTRACT_TILES_STAT_INPUT_DIR', - 'copy': False}, - 'EXTRACT_TILES_FILTERED_OUTPUT_TEMPLATE': {'sec': 'filename_template', - 'alt': 'EXTRACT_TILES_STAT_INPUT_TEMPLATE',}, - 'EXTRACT_TILES_GRID_INPUT_DIR': {'sec': 'dir', - 'alt': 'FCST_EXTRACT_TILES_INPUT_DIR' - 'and ' - 'OBS_EXTRACT_TILES_INPUT_DIR', - 'copy': False}, - 'SERIES_ANALYSIS_FILTER_OPTS': {'sec': 'config', - 'alt': 'TC_STAT_JOB_ARGS', - 'copy': False}, - 'SERIES_ANALYSIS_INPUT_DIR': {'sec': 'dir', - 'alt': 'FCST_SERIES_ANALYSIS_INPUT_DIR ' - 'and ' - 'OBS_SERIES_ANALYSIS_INPUT_DIR'}, - 'FCST_SERIES_ANALYSIS_TILE_INPUT_TEMPLATE': {'sec': 'filename_templates', - 'alt': 'FCST_SERIES_ANALYSIS_INPUT_TEMPLATE '}, - 'OBS_SERIES_ANALYSIS_TILE_INPUT_TEMPLATE': {'sec': 'filename_templates', - 'alt': 'OBS_SERIES_ANALYSIS_INPUT_TEMPLATE '}, - 'EXTRACT_TILES_STAT_INPUT_DIR': {'sec': 'dir', - 'alt': 'EXTRACT_TILES_TC_STAT_INPUT_DIR',}, - 'EXTRACT_TILES_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', - 'alt': 'EXTRACT_TILES_TC_STAT_INPUT_TEMPLATE',}, - 'SERIES_ANALYSIS_STAT_INPUT_DIR': {'sec': 'dir', - 'alt': 'SERIES_ANALYSIS_TC_STAT_INPUT_DIR', }, - 'SERIES_ANALYSIS_STAT_INPUT_TEMPLATE': {'sec': 'filename_templates', - 'alt': 'SERIES_ANALYSIS_TC_STAT_INPUT_TEMPLATE', }, - } - - # template '' : {'sec' : '', 'alt' : '', 'copy': True}, - - logger = config.logger - - # create list of errors and warnings to report for deprecated configs - e_list = [] - w_list = [] - all_sed_cmds = [] - - for old, depr_info in deprecated_dict.items(): - if isinstance(depr_info, dict): - - # check if is found in the old item, use regex to find variables if found - if '' in old: - old_regex = old.replace('', r'(\d+)') - indicies = find_indices_in_config_section(old_regex, - config, - index_index=1).keys() - for index in indicies: - old_with_index = old.replace('', index) - if depr_info['alt']: - alt_with_index = depr_info['alt'].replace('', index) - else: - alt_with_index = '' - - handle_deprecated(old_with_index, alt_with_index, depr_info, - config, all_sed_cmds, w_list, e_list) - else: - handle_deprecated(old, depr_info['alt'], depr_info, - config, all_sed_cmds, w_list, e_list) - - - # check all templates and error if any deprecated tags are used - # value of dict is replacement tag, set to None if no replacement exists - # deprecated tags: region (replace with basin) - deprecated_tags = {'region' : 'basin'} - template_vars = config.keys('config') - template_vars = [tvar for tvar in template_vars if tvar.endswith('_TEMPLATE')] - for temp_var in template_vars: - template = config.getraw('filename_templates', temp_var) - tags = get_tags(template) - - for depr_tag, replace_tag in deprecated_tags.items(): - if depr_tag in tags: - e_msg = 'Deprecated tag {{{}}} found in {}.'.format(depr_tag, - temp_var) - if replace_tag is not None: - e_msg += ' Replace with {{{}}}'.format(replace_tag) - - e_list.append(e_msg) - - # if any warning exist, report them - if w_list: - for warning_msg in w_list: - logger.warning(warning_msg) - - # if any errors exist, report them and exit - if e_list: - logger.error('DEPRECATED CONFIG ITEMS WERE FOUND. ' +\ - 'PLEASE REMOVE/REPLACE THEM FROM CONFIG FILES') - for error_msg in e_list: - logger.error(error_msg) - return False, all_sed_cmds - - return True, [] - -def handle_deprecated(old, alt, depr_info, config, all_sed_cmds, w_list, e_list): - sec = depr_info['sec'] - config_files = config.getstr('config', 'CONFIG_INPUT', '').split(',') - # if deprecated config item is found - if config.has_option(sec, old): - # if it is not required to remove, add to warning list - if 'req' in depr_info.keys() and depr_info['req'] is False: - msg = '[{}] {} is deprecated and will be '.format(sec, old) + \ - 'removed in a future version of METplus' - if alt: - msg += ". Please replace with {}".format(alt) - w_list.append(msg) - # if it is required to remove, add to error list - else: - if not alt: - e_list.append("[{}] {} should be removed".format(sec, old)) - else: - e_list.append("[{}] {} should be replaced with {}".format(sec, old, alt)) - - if 'copy' not in depr_info.keys() or depr_info['copy']: - for config_file in config_files: - all_sed_cmds.append(f"sed -i 's|^{old}|{alt}|g' {config_file}") - all_sed_cmds.append(f"sed -i 's|{{{old}}}|{{{alt}}}|g' {config_file}") - -def check_for_deprecated_met_config(config): - sed_cmds = [] - all_good = True - - # set CURRENT_* METplus variables in case they are referenced in a - # METplus config variable and not already set - for fcst_or_obs in ['FCST', 'OBS']: - for name_or_level in ['NAME', 'LEVEL']: - current_var = f'CURRENT_{fcst_or_obs}_{name_or_level}' - if not config.has_option('config', current_var): - config.set('config', current_var, '') - - # check if *_CONFIG_FILE if set in the METplus config file and check for - # deprecated environment variables in those files - met_config_keys = [key for key in config.keys('config') - if key.endswith('CONFIG_FILE')] - - for met_config_key in met_config_keys: - met_tool = met_config_key.replace('_CONFIG_FILE', '') - - # get custom loop list to check if multiple config files are used based on the custom string - custom_list = get_custom_string_list(config, met_tool) - - for custom_string in custom_list: - met_config = config.getraw('config', met_config_key) - if not met_config: - continue - - met_config_file = do_string_sub(met_config, custom=custom_string) - - if not check_for_deprecated_met_config_file(config, met_config_file, sed_cmds, met_tool): - all_good = False - - return all_good, sed_cmds - -def check_for_deprecated_met_config_file(config, met_config, sed_cmds, met_tool): - - all_good = True - if not os.path.exists(met_config): - config.logger.error(f"Config file does not exist: {met_config}") - return False - - deprecated_met_list = ['MET_VALID_HHMM', 'GRID_VX', 'CONFIG_DIR'] - deprecated_output_prefix_list = ['FCST_VAR', 'OBS_VAR'] - config.logger.debug(f"Checking for deprecated environment variables in: {met_config}") - - with open(met_config, 'r') as file_handle: - lines = file_handle.read().splitlines() - - for line in lines: - for deprecated_item in deprecated_met_list: - if '${' + deprecated_item + '}' in line: - all_good = False - config.logger.error("Please remove deprecated environment variable " - f"${{{deprecated_item}}} found in MET config file: " - f"{met_config}") - - if deprecated_item == 'MET_VALID_HHMM' and 'file_name' in line: - config.logger.error(f"Set {met_tool}_CLIMO_MEAN_INPUT_[DIR/TEMPLATE] in a " - "METplus config file to set CLIMO_MEAN_FILE in a MET config") - new_line = " file_name = [ ${CLIMO_MEAN_FILE} ];" - - # escape [ and ] because they are special characters in sed commands - old_line = line.rstrip().replace('[', r'\[').replace(']', r'\]') - - sed_cmds.append(f"sed -i 's|^{old_line}|{new_line}|g' {met_config}") - add_line = f"{met_tool}_CLIMO_MEAN_INPUT_TEMPLATE" - sed_cmds.append(f"#Add {add_line}") - break - - if 'to_grid' in line: - config.logger.error("MET to_grid variable should reference " - "${REGRID_TO_GRID} environment variable") - new_line = " to_grid = ${REGRID_TO_GRID};" - - # escape [ and ] because they are special characters in sed commands - old_line = line.rstrip().replace('[', r'\[').replace(']', r'\]') - - sed_cmds.append(f"sed -i 's|^{old_line}|{new_line}|g' {met_config}") - config.logger.info(f"Be sure to set {met_tool}_REGRID_TO_GRID to the correct value.") - add_line = f"{met_tool}_REGRID_TO_GRID" - sed_cmds.append(f"#Add {add_line}") - break - - - for deprecated_item in deprecated_output_prefix_list: - # if deprecated item found in output prefix or to_grid line, replace line to use - # env var OUTPUT_PREFIX or REGRID_TO_GRID - if '${' + deprecated_item + '}' in line and 'output_prefix' in line: - config.logger.error("output_prefix variable should reference " - "${OUTPUT_PREFIX} environment variable") - new_line = "output_prefix = \"${OUTPUT_PREFIX}\";" - - # escape [ and ] because they are special characters in sed commands - old_line = line.rstrip().replace('[', r'\[').replace(']', r'\]') - - sed_cmds.append(f"sed -i 's|^{old_line}|{new_line}|g' {met_config}") - config.logger.info(f"You will need to add {met_tool}_OUTPUT_PREFIX to the METplus config file" - f" that sets {met_tool}_CONFIG_FILE. Set it to:") - output_prefix = replace_output_prefix(line) - add_line = f"{met_tool}_OUTPUT_PREFIX = {output_prefix}" - config.logger.info(add_line) - sed_cmds.append(f"#Add {add_line}") - all_good = False - break - - return all_good - -def replace_output_prefix(line): - op_replacements = {'${MODEL}': '{MODEL}', - '${FCST_VAR}': '{CURRENT_FCST_NAME}', - '${OBTYPE}': '{OBTYPE}', - '${OBS_VAR}': '{CURRENT_OBS_NAME}', - '${LEVEL}': '{CURRENT_FCST_LEVEL}', - '${FCST_TIME}': '{lead?fmt=%3H}', - } - prefix = line.split('=')[1].strip().rstrip(';').strip('"') - for key, value, in op_replacements.items(): - prefix = prefix.replace(key, value) - - return prefix - -def get_custom_string_list(config, met_tool): - custom_loop_list = config.getstr_nocheck('config', - f'{met_tool.upper()}_CUSTOM_LOOP_LIST', - config.getstr_nocheck('config', - 'CUSTOM_LOOP_LIST', - '')) - custom_loop_list = getlist(custom_loop_list) - if not custom_loop_list: - custom_loop_list.append('') - - return custom_loop_list - def handle_tmp_dir(config): """! if env var MET_TMP_DIR is set, override config TMP_DIR with value if it differs from what is set @@ -1276,18 +774,6 @@ def round_0p5(val): return round(val * 2) / 2 -def round_to_int(val): - """! Round to integer value - Args: - @param val: The value to round up - Returns: - rval: The rounded up value. - """ - val += 0.5 - rval = int(val) - return rval - - def mkdir_p(path): """! From stackoverflow.com/questions/600268/mkdir-p-functionality-in-python @@ -1301,138 +787,6 @@ def mkdir_p(path): """ Path(path).mkdir(parents=True, exist_ok=True) -def _rmtree_onerr(function, path, exc_info, logger=None): - """!Internal function used to log errors. - This is an internal implementation function called by - shutil.rmtree when an underlying function call failed. See - the Python documentation of shutil.rmtree for details. - @param function the funciton that failed - @param path the path to the function that caused problems - @param exc_info the exception information - @protected""" - if logger: - logger.warning('%s: %s failed: %s' % ( - str(path), str(function), str(exc_info))) - - -def rmtree(tree, logger=None): - """!Deletes the tree, if possible. - @protected - @param tree the directory tree to delete" - @param logger the logger, optional - """ - try: - # If it is a file, special file or symlink we can just - # delete it via unlink: - os.unlink(tree) - return - except EnvironmentError: - pass - # We get here for directories. - if logger: - logger.info('%s: rmtree' % (tree,)) - shutil.rmtree(tree, ignore_errors=False) - -def file_exists(filename): - """! Determines if a file exists - NOTE: Simply using os.path.isfile() is not a Pythonic way - to check if a file exists. You can - still encounter a TOCTTOU bug - "time of check to time of use" - Instead, use the raising of - exceptions, which is a Pythonic - approach: - try: - with open(filename) as fileobj: - pass # or do something fruitful - except IOError as e: - logger.error("your helpful error message goes here") - Args: - @param filename: the full filename (full path) - Returns: - boolean : True if file exists, False otherwise - """ - - try: - return os.path.isfile(filename) - except IOError: - pass - - -def is_dir_empty(directory): - """! Determines if a directory exists and is not empty - Args: - @param directory: The directory to check for existence - and for contents. - Returns: - True: If the directory is empty - False: If the directory exists and isn't empty - """ - return not os.listdir(directory) - -def grep(pattern, infile): - """! Python version of grep, searches the file line-by-line - to find a match to the pattern. Returns upon finding the - first match. - Args: - @param pattern: The pattern to be matched - @param infile: The filename with full filepath in which to - search for the pattern - Returns: - line (string): The matching string - """ - - matching_lines = [] - with open(infile, 'r') as file_handle: - for line in file_handle: - match = re.search(pattern, line) - if match: - matching_lines.append(line) - # if you got here, you didn't find anything - return matching_lines - - -def get_filepaths_for_grbfiles(base_dir): - """! Generates the grb2 file names in a directory tree - by walking the tree either top-down or bottom-up. - For each directory in the tree rooted at - the directory top (including top itself), it - produces a tuple: (dirpath, dirnames, filenames). - This solution was found on Stack Overflow: - http://stackoverflow.com/questions/3207219/how-to-list-all-files-of-a- - directory-in-python#3207973 - **scroll down to the section with "Getting Full File Paths From a - Directory and All Its Subdirectories" - Args: - @param base_dir: The base directory from which we - begin the search for grib2 filenames. - Returns: - file_paths (list): A list of the full filepaths - of the data to be processed. - """ - - # Create an empty list which will eventually store - # all the full filenames - file_paths = [] - - # pylint:disable=unused-variable - # os.walk returns tuple, we don't need to utilize all the returned - # values in the tuple. - - # Walk the tree - for root, directories, files in os.walk(base_dir): - for filename in files: - # add it to the list only if it is a grib file - match = re.match(r'.*(grib|grb|grib2|grb2)$', filename) - if match: - # Join the two strings to form the full - # filepath. - filepath = os.path.join(root, filename) - file_paths.append(filepath) - else: - continue - return file_paths - def get_storms(filter_filename, id_only=False, sort_column='STORM_ID'): """! Get each storm as identified by a column in the input file. Create dictionary storm ID as the key and a list of lines for that @@ -1476,18 +830,6 @@ def get_storms(filter_filename, id_only=False, sort_column='STORM_ID'): return storm_dict -def get_storm_ids(filter_filename): - """! Get each storm as identified by its STORM_ID in the filter file - save these in a set so we only save the unique ids and sort them. - Args: - @param filter_filename: The name of the filter file to read - and extract the storm id - @param logger: The name of the logger for logging useful info - Returns: - sorted_storms (List): a list of unique, sorted storm ids - """ - return get_storms(filter_filename, id_only=True) - def get_files(filedir, filename_regex, logger=None): """! Get all the files (with a particular naming format) by walking @@ -1742,68 +1084,6 @@ def camel_to_underscore(camel): s1 = re.sub(r'([^\d])([A-Z][a-z]+)', r'\1_\2', camel) return re.sub(r'([a-z])([A-Z])', r'\1_\2', s1).lower() -def get_process_list(config): - """!Read process list, Extract instance string if specified inside - parenthesis. Remove dashes/underscores and change to lower case, - then map the name to the correct wrapper name - - @param config METplusConfig object to read PROCESS_LIST value - @returns list of tuple containing process name and instance identifier - (None if no instance was set) - """ - # get list of processes - process_list = getlist(config.getstr('config', 'PROCESS_LIST')) - - out_process_list = [] - # for each item remove dashes, underscores, and cast to lower-case - for process in process_list: - # if instance is specified, extract the text inside parenthesis - match = re.match(r'(.*)\((.*)\)', process) - if match: - instance = match.group(2) - process_name = match.group(1) - else: - instance = None - process_name = process - - wrapper_name = get_wrapper_name(process_name) - if wrapper_name is None: - config.logger.warning(f"PROCESS_LIST item {process_name} " - "may be invalid.") - wrapper_name = process_name - - # if MakePlots is in process list, remove it because - # it will be called directly from StatAnalysis - if wrapper_name == 'MakePlots': - continue - - out_process_list.append((wrapper_name, instance)) - - return out_process_list - -# minutes -def shift_time(time_str, shift): - """ Adjust time by shift hours. Format is %Y%m%d%H%M%S - Args: - @param time_str: Start time in %Y%m%d%H%M%S - @param shift: Amount to adjust time in hours - Returns: - New time in format %Y%m%d%H%M%S - """ - return (datetime.datetime.strptime(time_str, "%Y%m%d%H%M%S") + - datetime.timedelta(hours=shift)).strftime("%Y%m%d%H%M%S") - -def shift_time_minutes(time_str, shift): - """ Adjust time by shift minutes. Format is %Y%m%d%H%M%S - Args: - @param time_str: Start time in %Y%m%d%H%M%S - @param shift: Amount to adjust time in minutes - Returns: - New time in format %Y%m%d%H%M%S - """ - return (datetime.datetime.strptime(time_str, "%Y%m%d%H%M%S") + - datetime.timedelta(minutes=shift)).strftime("%Y%m%d%H%M%S") - def shift_time_seconds(time_str, shift): """ Adjust time by shift seconds. Format is %Y%m%d%H%M%S Args: @@ -1903,298 +1183,6 @@ def write_list_to_file(filename, output_list): for line in output_list: f.write(f"{line}\n") -def validate_configuration_variables(config, force_check=False): - - all_sed_cmds = [] - # check for deprecated config items and warn user to remove/replace them - deprecated_isOK, sed_cmds = check_for_deprecated_config(config) - all_sed_cmds.extend(sed_cmds) - - # check for deprecated env vars in MET config files and warn user to remove/replace them - deprecatedMET_isOK, sed_cmds = check_for_deprecated_met_config(config) - all_sed_cmds.extend(sed_cmds) - - # validate configuration variables - field_isOK, sed_cmds = validate_field_info_configs(config, force_check) - all_sed_cmds.extend(sed_cmds) - - # check that OUTPUT_BASE is not set to the exact same value as INPUT_BASE - inoutbase_isOK = True - input_real_path = os.path.realpath(config.getdir_nocheck('INPUT_BASE', '')) - output_real_path = os.path.realpath(config.getdir('OUTPUT_BASE')) - if input_real_path == output_real_path: - config.logger.error(f"INPUT_BASE AND OUTPUT_BASE are set to the exact same path: {input_real_path}") - config.logger.error("Please change one of these paths to avoid risk of losing input data") - inoutbase_isOK = False - - check_user_environment(config) - - return deprecated_isOK, field_isOK, inoutbase_isOK, deprecatedMET_isOK, all_sed_cmds - -def skip_field_info_validation(config): - """!Check config to see if having corresponding FCST/OBS variables is necessary. If process list only - contains reformatter wrappers, don't validate field info. Also, if MTD is in the process list and - it is configured to only process either FCST or OBS, validation is unnecessary.""" - - reformatters = ['PCPCombine', 'RegridDataPlane'] - process_list = [item[0] for item in get_process_list(config)] - - # if running MTD in single mode, you don't need matching FCST/OBS - if 'MTD' in process_list and config.getbool('config', 'MTD_SINGLE_RUN'): - return True - - # if running any app other than the reformatters, you need matching FCST/OBS, so don't skip - if [item for item in process_list if item not in reformatters]: - return False - - return True - -def find_indices_in_config_section(regex, config, sec='config', - index_index=1, id_index=None): - """! Use regular expression to get all config variables that match and - are set in the user's configuration. This is used to handle config - variables that have multiple indices, i.e. FCST_VAR1_NAME, FCST_VAR2_NAME, - etc. - - @param regex regular expression to use to find variables - @param config METplusConfig object to search - @param sec (optional) config file section to search. Defaults to config - @param index_index 1 based number that is the regex match index for the - index number (default is 1) - @param id_index 1 based number that is the regex match index for the - identifier. Defaults to None which does not extract an indentifier - - number and the first match is used as an identifier - @returns dictionary where keys are the index number and the value is a - list of identifiers (if noID=True) or a list containing None - """ - # regex expression must have 2 () items and the 2nd item must be the index - all_conf = config.keys(sec) - indices = {} - regex = re.compile(regex) - for conf in all_conf: - result = regex.match(conf) - if result is not None: - index = result.group(index_index) - if id_index: - identifier = result.group(id_index) - else: - identifier = None - - if index not in indices: - indices[index] = [identifier] - else: - indices[index].append(identifier) - - return indices - -def is_var_item_valid(item_list, index, ext, config): - """!Given a list of data types (FCST, OBS, ENS, or BOTH) check if the - combination is valid. - If BOTH is found, FCST and OBS should not be found. - If FCST or OBS is found, the other must also be found. - @param item_list list of data types that were found for a given index - @param index number following _VAR in the variable name - @param ext extension to check, i.e. NAME, LEVELS, THRESH, or OPTIONS - @param config METplusConfig instance - @returns tuple containing boolean if var item is valid, list of error - messages and list of sed commands to help the user update their old - configuration files - """ - - full_ext = f"_VAR{index}_{ext}" - msg = [] - sed_cmds = [] - if 'BOTH' in item_list and ('FCST' in item_list or 'OBS' in item_list): - - msg.append(f"Cannot set FCST{full_ext} or OBS{full_ext} if BOTH{full_ext} is set.") - elif ext == 'THRESH': - # allow thresholds unless BOTH and (FCST or OBS) are set - pass - - elif 'FCST' in item_list and 'OBS' not in item_list: - # if FCST level has 1 item and OBS name is a python embedding script, - # don't report error - level_list = getlist(config.getraw('config', - f'FCST_VAR{index}_LEVELS', - '')) - other_name = config.getraw('config', f'OBS_VAR{index}_NAME', '') - skip_error_for_py_embed = ext == 'LEVELS' and is_python_script(other_name) and len(level_list) == 1 - # do not report error for OPTIONS since it isn't required to be the same length - if ext not in ['OPTIONS'] and not skip_error_for_py_embed: - msg.append(f"If FCST{full_ext} is set, you must either set OBS{full_ext} or " - f"change FCST{full_ext} to BOTH{full_ext}") - - config_files = config.getstr('config', 'CONFIG_INPUT', '').split(',') - for config_file in config_files: - sed_cmds.append(f"sed -i 's|^FCST{full_ext}|BOTH{full_ext}|g' {config_file}") - sed_cmds.append(f"sed -i 's|{{FCST{full_ext}}}|{{BOTH{full_ext}}}|g' {config_file}") - - elif 'OBS' in item_list and 'FCST' not in item_list: - # if OBS level has 1 item and FCST name is a python embedding script, - # don't report error - level_list = getlist(config.getraw('config', - f'OBS_VAR{index}_LEVELS', - '')) - other_name = config.getraw('config', f'FCST_VAR{index}_NAME', '') - skip_error_for_py_embed = ext == 'LEVELS' and is_python_script(other_name) and len(level_list) == 1 - - if ext not in ['OPTIONS'] and not skip_error_for_py_embed: - msg.append(f"If OBS{full_ext} is set, you must either set FCST{full_ext} or " - f"change OBS{full_ext} to BOTH{full_ext}") - - config_files = config.getstr('config', 'CONFIG_INPUT', '').split(',') - for config_file in config_files: - sed_cmds.append(f"sed -i 's|^OBS{full_ext}|BOTH{full_ext}|g' {config_file}") - sed_cmds.append(f"sed -i 's|{{OBS{full_ext}}}|{{BOTH{full_ext}}}|g' {config_file}") - - return not bool(msg), msg, sed_cmds - -def validate_field_info_configs(config, force_check=False): - """!Verify that config variables with _VAR_ in them are valid. Returns True if all are valid. - Returns False if any items are invalid""" - - variable_extensions = ['NAME', 'LEVELS', 'THRESH', 'OPTIONS'] - all_good = True, [] - - if skip_field_info_validation(config) and not force_check: - return True, [] - - # keep track of all sed commands to replace config variable names - all_sed_cmds = [] - - for ext in variable_extensions: - # find all _VAR_ keys in the conf files - data_types_and_indices = find_indices_in_config_section(r"(\w+)_VAR(\d+)_"+ext, - config, - index_index=2, - id_index=1) - - # if BOTH_VAR_ is used, set FCST and OBS to the same value - # if FCST or OBS is used, the other must be present as well - # if BOTH and either FCST or OBS are set, report an error - # get other data type - for index, data_type_list in data_types_and_indices.items(): - - is_valid, err_msgs, sed_cmds = is_var_item_valid(data_type_list, index, ext, config) - if not is_valid: - for err_msg in err_msgs: - config.logger.error(err_msg) - all_sed_cmds.extend(sed_cmds) - all_good = False - - # make sure FCST and OBS have the same number of levels if coming from separate variables - elif ext == 'LEVELS' and all(item in ['FCST', 'OBS'] for item in data_type_list): - fcst_levels = getlist(config.getraw('config', f"FCST_VAR{index}_LEVELS", '')) - - # add empty string if no levels are found because python embedding items do not need - # to include a level, but the other item may have a level and the numbers need to match - if not fcst_levels: - fcst_levels.append('') - - obs_levels = getlist(config.getraw('config', f"OBS_VAR{index}_LEVELS", '')) - if not obs_levels: - obs_levels.append('') - - if len(fcst_levels) != len(obs_levels): - config.logger.error(f"FCST_VAR{index}_LEVELS and OBS_VAR{index}_LEVELS do not have " - "the same number of elements") - all_good = False - - return all_good, all_sed_cmds - -def get_field_search_prefixes(data_type, met_tool=None): - """! Get list of prefixes to search for field variables. - - @param data_type type of field to search for, i.e. FCST, OBS, ENS, etc. - Check for BOTH_ variables first only if data type is FCST or OBS - @param met_tool name of tool to search for variable or None if looking - for generic field info - @returns list of prefixes to search, i.e. [BOTH_, FCST_] or - [ENS_] or [BOTH_GRID_STAT_, OBS_GRID_STAT_] - """ - search_prefixes = [] - var_strings = [] - - # if met tool name is set, prioritize - # wrapper-specific configs before generic configs - if met_tool: - var_strings.append(f'{met_tool.upper()}_') - - var_strings.append('') - - for var_string in var_strings: - search_prefixes.append(f"{data_type}_{var_string}") - - # if looking for FCST or OBS, also check for BOTH prefix - if data_type in ['FCST', 'OBS']: - search_prefixes.append(f"BOTH_{var_string}") - - return search_prefixes - -def get_field_config_variables(config, index, search_prefixes): - """! Search for variables that are set in the config that correspond to - the fields requested. Some field info items have - synonyms that can be used if the typical name is not set. This is used - in RegridDataPlane wrapper. - - @param config METplusConfig object to search - @param index of field (VAR) to find - @param search_prefixes list of valid prefixes to search for variables - in the config, i.e. FCST_VAR1_ or OBS_GRID_STAT_VAR2_ - @returns dictionary containing a config variable name to be used for - each field info value. If a valid config variable was not set for a - field info value, the value for that key will be set to None. - """ - # list of field info variables to find from config - # used as keys for dictionaries - field_info_items = ['name', - 'levels', - 'thresh', - 'options', - 'output_names', - ] - - field_configs = {} - search_suffixes = {} - - # initialize field configs dictionary values to None - # initialize dictionary of valid suffixes to search for with - # the capitalized version of field info name - for field_info_item in field_info_items: - field_configs[field_info_item] = None - search_suffixes[field_info_item] = [field_info_item.upper()] - - # add alternate suffixes for config variable names to attempt - search_suffixes['name'].append('INPUT_FIELD_NAME') - search_suffixes['name'].append('FIELD_NAME') - search_suffixes['levels'].append('INPUT_LEVEL') - search_suffixes['levels'].append('FIELD_LEVEL') - search_suffixes['output_names'].append('OUTPUT_FIELD_NAME') - search_suffixes['output_names'].append('FIELD_NAME') - - # look through field config keys and obtain highest priority - # variable name for each field config - for search_var, suffixes in search_suffixes.items(): - for prefix in search_prefixes: - - found = False - for suffix in suffixes: - var_name = f"{prefix}VAR{index}_{suffix}" - # if variable is found in config, - # get the value and break out of suffix loop - if config.has_option('config', var_name): - field_configs[search_var] = config.getraw('config', - var_name) - found = True - break - - # if config variable was found, break out of prefix loop - if found: - break - - return field_configs - def format_var_items(field_configs, time_info=None): """! Substitute time information into field information and format values. @@ -2281,188 +1269,6 @@ def format_var_items(field_configs, time_info=None): return var_items -def find_var_name_indices(config, data_types, met_tool=None): - data_type_regex = f"{'|'.join(data_types)}" - - # if data_types includes FCST or OBS, also search for BOTH - if any([item for item in ['FCST', 'OBS'] if item in data_types]): - data_type_regex += '|BOTH' - - regex_string = f"({data_type_regex})" - - # if MET tool is specified, get tool specific items - if met_tool: - regex_string += f"_{met_tool.upper()}" - - regex_string += r"_VAR(\d+)_(NAME|INPUT_FIELD_NAME|FIELD_NAME)" - - # find all _VAR_NAME keys in the conf files - return find_indices_in_config_section(regex_string, - config, - index_index=2, - id_index=1) - -def parse_var_list(config, time_info=None, data_type=None, met_tool=None, - levels_as_list=False): - """ read conf items and populate list of dictionaries containing - information about each variable to be compared - - @param config: METplusConfig object - @param time_info: time object for string sub, optional - @param data_type: data type to find. Can be FCST, OBS, or ENS. - If not set, get FCST/OBS/BOTH - @param met_tool: optional name of MET tool to look for wrapper - specific var items - @param levels_as_list If true, store levels and output names as - a list instead of creating a field info dict for each name/level - @returns list of dictionaries with variable information - """ - - # validate configs again in case wrapper is not running from run_metplus - # this does not need to be done if parsing a specific data type, - # i.e. ENS or FCST - if data_type is None: - if not validate_field_info_configs(config)[0]: - return [] - elif data_type == 'BOTH': - config.logger.error("Cannot request BOTH explicitly in parse_var_list") - return [] - - # var_list is a list containing an list of dictionaries - var_list = [] - - # if specific data type is requested, only get that type - if data_type: - data_types = [data_type] - # otherwise get both FCST and OBS - else: - data_types = ['FCST', 'OBS'] - - # get indices of VAR items for data type and/or met tool - indices = [] - if met_tool: - indices = find_var_name_indices(config, data_types, met_tool).keys() - if not indices: - indices = find_var_name_indices(config, data_types).keys() - - # get config name prefixes for each data type to find - dt_search_prefixes = {} - for current_type in data_types: - # get list of variable prefixes to search - prefixes = get_field_search_prefixes(current_type, met_tool) - dt_search_prefixes[current_type] = prefixes - - # loop over all possible variables and add them to list - for index in indices: - field_info_list = [] - for current_type in data_types: - # get dictionary of existing config variables to use - search_prefixes = dt_search_prefixes[current_type] - field_configs = get_field_config_variables(config, - index, - search_prefixes) - - field_info = format_var_items(field_configs, time_info) - if not isinstance(field_info, dict): - config.logger.error(f'Could not process {current_type}_' - f'VAR{index} variables: {field_info}') - continue - - field_info['data_type'] = current_type.lower() - field_info_list.append(field_info) - - # check that all fields types were found - if not field_info_list or len(data_types) != len(field_info_list): - continue - - # check if number of levels for each field type matches - n_levels = len(field_info_list[0]['levels']) - if len(data_types) > 1: - if (n_levels != len(field_info_list[1]['levels'])): - continue - - # if requested, put all field levels in a single item - if levels_as_list: - var_dict = {} - for field_info in field_info_list: - current_type = field_info.get('data_type') - var_dict[f"{current_type}_name"] = field_info.get('name') - var_dict[f"{current_type}_level"] = field_info.get('levels') - var_dict[f"{current_type}_thresh"] = field_info.get('thresh') - var_dict[f"{current_type}_extra"] = field_info.get('extra') - var_dict[f"{current_type}_output_name"] = field_info.get('output_names') - - var_dict['index'] = index - var_list.append(var_dict) - continue - - # loop over levels and add all values to output dictionary - for level_index in range(n_levels): - var_dict = {} - - # get level values to use for string substitution in name - # used for python embedding calls that read the level value - sub_info = {} - for field_info in field_info_list: - dt_level = f"{field_info.get('data_type')}_level" - sub_info[dt_level] = field_info.get('levels')[level_index] - - for field_info in field_info_list: - current_type = field_info.get('data_type') - name = field_info.get('name') - level = field_info.get('levels')[level_index] - thresh = field_info.get('thresh') - extra = field_info.get('extra') - output_name = field_info.get('output_names')[level_index] - - # substitute level in name if filename template is specified - subbed_name = do_string_sub(name, - skip_missing_tags=True, - **sub_info) - - var_dict[f"{current_type}_name"] = subbed_name - var_dict[f"{current_type}_level"] = level - var_dict[f"{current_type}_thresh"] = thresh - var_dict[f"{current_type}_extra"] = extra - var_dict[f"{current_type}_output_name"] = output_name - - var_dict['index'] = index - var_list.append(var_dict) - - # extra debugging information used for developer debugging only - ''' - for v in var_list: - config.logger.debug(f"VAR{v['index']}:") - if 'fcst_name' in v.keys(): - config.logger.debug(" fcst_name:"+v['fcst_name']) - config.logger.debug(" fcst_level:"+v['fcst_level']) - if 'fcst_thresh' in v.keys(): - config.logger.debug(" fcst_thresh:"+str(v['fcst_thresh'])) - if 'fcst_extra' in v.keys(): - config.logger.debug(" fcst_extra:"+v['fcst_extra']) - if 'fcst_output_name' in v.keys(): - config.logger.debug(" fcst_output_name:"+v['fcst_output_name']) - if 'obs_name' in v.keys(): - config.logger.debug(" obs_name:"+v['obs_name']) - config.logger.debug(" obs_level:"+v['obs_level']) - if 'obs_thresh' in v.keys(): - config.logger.debug(" obs_thresh:"+str(v['obs_thresh'])) - if 'obs_extra' in v.keys(): - config.logger.debug(" obs_extra:"+v['obs_extra']) - if 'obs_output_name' in v.keys(): - config.logger.debug(" obs_output_name:"+v['obs_output_name']) - if 'ens_name' in v.keys(): - config.logger.debug(" ens_name:"+v['ens_name']) - config.logger.debug(" ens_level:"+v['ens_level']) - if 'ens_thresh' in v.keys(): - config.logger.debug(" ens_thresh:"+str(v['ens_thresh'])) - if 'ens_extra' in v.keys(): - config.logger.debug(" ens_extra:"+v['ens_extra']) - if 'ens_output_name' in v.keys(): - config.logger.debug(" ens_output_name:"+v['ens_output_name']) - ''' - return sorted(var_list, key=lambda x: x['index']) - def sub_var_info(var_info, time_info): if not var_info: return {} @@ -2652,31 +1458,6 @@ def get_filetype(filepath, logger=None): #else: # return None - - -def get_time_from_file(filepath, template, logger=None): - """! Extract time information from path using the filename template - Args: - @param filepath path to examine - @param template filename template to use to extract time information - @returns time_info dictionary with time information if successful, None if not - """ - if os.path.isdir(filepath): - return None - - out = parse_template(template, filepath, logger) - if out is not None: - return out - - # check to see if zip extension ends file path, try again without extension - for ext in VALID_EXTENSIONS: - if filepath.endswith(ext): - out = parse_template(template, filepath[:-len(ext)], logger) - if out is not None: - return out - - return None - def preprocess_file(filename, data_type, config, allow_dir=False): """ Decompress gzip, bzip, or zip files or convert Gempak files to NetCDF Args: @@ -2707,7 +1488,7 @@ def preprocess_file(filename, data_type, config, allow_dir=False): # the function will handle files passed to it with an # extension the same way as files passed # without an extension but the compressed equivalent exists - for ext in VALID_EXTENSIONS: + for ext in COMPRESSION_EXTENSIONS: if filename.endswith(ext): return preprocess_file(filename[:-len(ext)], data_type, config) # if extension is grd (Gempak), then look in staging dir for nc file @@ -2751,8 +1532,8 @@ def preprocess_file(filename, data_type, config, allow_dir=False): return outpath # Create staging area directory only if file has compression extension - valid_extensions = ['gz', 'bz2', 'zip'] - if any([os.path.isfile(f'{filename}.{ext}') for ext in valid_extensions]): + if any([os.path.isfile(f'{filename}{ext}') + for ext in COMPRESSION_EXTENSIONS]): outdir = os.path.dirname(outpath) if not os.path.exists(outdir): os.makedirs(outdir, mode=0o0775) @@ -2812,18 +1593,6 @@ def is_python_script(name): return False -def check_user_environment(config): - """!Check if any environment variables set in [user_env_vars] are already set in - the user's environment. Warn them that it will be overwritten from the conf if it is""" - if not config.has_section('user_env_vars'): - return - - for env_var in config.keys('user_env_vars'): - if env_var in os.environ: - msg = '{} is already set in the environment. '.format(env_var) +\ - 'Overwriting from conf file' - config.logger.warning(msg) - def expand_int_string_to_list(int_string): """! Expand string into a list of integer values. Items are separated by commas. Items that are formatted X-Y will be expanded into each number diff --git a/metplus/util/string_template_substitution.py b/metplus/util/string_template_substitution.py index 05dd19fc73..e4085ebc50 100644 --- a/metplus/util/string_template_substitution.py +++ b/metplus/util/string_template_substitution.py @@ -12,11 +12,13 @@ """ +import os import re import datetime from dateutil.relativedelta import relativedelta from . import time_util +from .constants import * TEMPLATE_IDENTIFIER_BEGIN = "{" TEMPLATE_IDENTIFIER_END = "}" @@ -828,10 +830,26 @@ def add_offset_matches_to_output_dict(match_dict, output_dict): output_dict['offset_hours'] = offset -def extract_lead(template, filename): - new_template = template - new_template = new_template.replace('/', '\/').replace('.', '\.') - match_tags = re.findall(r'{(.*?)}', new_template) - for match_tag in match_tags: - if match_tag.split('?') != 'lead': - new_template = new_template.replace('{' + match_tag + '}', '.*') +def get_time_from_file(filepath, template, logger=None): + """! Extract time information from path using the filename template + + @param filepath path to examine + @param template filename template to use to extract time information + @param logger optional logging object + @returns dictionary with time information if successful, None if not + """ + if os.path.isdir(filepath): + return None + + out = parse_template(template, filepath, logger) + if out is not None: + return out + + # check to see if zip extension ends file path, try again without extension + for ext in COMPRESSION_EXTENSIONS: + if filepath.endswith(ext): + out = parse_template(template, filepath[:-len(ext)], logger) + if out is not None: + return out + + return None diff --git a/metplus/wrappers/ascii2nc_wrapper.py b/metplus/wrappers/ascii2nc_wrapper.py index 01e675833f..e68d78ac23 100755 --- a/metplus/wrappers/ascii2nc_wrapper.py +++ b/metplus/wrappers/ascii2nc_wrapper.py @@ -76,11 +76,8 @@ def create_c_dict(self): ) # MET config variables - self.handle_time_summary_legacy(c_dict, - ['TIME_SUMMARY_GRIB_CODES', - 'TIME_SUMMARY_VAR_NAMES', - 'TIME_SUMMARY_TYPES'] - ) + self.handle_time_summary_dict() + self.handle_time_summary_legacy() # handle file window variables for edge in ['BEGIN', 'END']: @@ -100,34 +97,115 @@ def create_c_dict(self): return c_dict + def handle_time_summary_legacy(self): + """! Read METplusConfig variables for the MET config time_summary + dictionary and format values into environment variable + METPLUS_TIME_SUMMARY_DICT as well as other environment variables + that contain individuals items of the time_summary dictionary + that were referenced in wrapped MET config files prior to METplus 4.0. + Developer note: If we discontinue support for legacy wrapped MET + config files + + @param c_dict dictionary to store time_summary item values + @param remove_bracket_list (optional) list of items that need the + square brackets around the value removed because the legacy (pre 4.0) + wrapped MET config includes square braces around the environment + variable. + """ + # handle legacy time summary variables + self.add_met_config(name='', + data_type='bool', + env_var_name='TIME_SUMMARY_FLAG', + metplus_configs=['ASCII2NC_TIME_SUMMARY_FLAG']) + + self.add_met_config(name='', + data_type='bool', + env_var_name='TIME_SUMMARY_RAW_DATA', + metplus_configs=['ASCII2NC_TIME_SUMMARY_RAW_DATA']) + + self.add_met_config(name='', + data_type='string', + env_var_name='TIME_SUMMARY_BEG', + metplus_configs=['ASCII2NC_TIME_SUMMARY_BEG']) + + self.add_met_config(name='', + data_type='string', + env_var_name='TIME_SUMMARY_END', + metplus_configs=['ASCII2NC_TIME_SUMMARY_END']) + + self.add_met_config(name='', + data_type='int', + env_var_name='TIME_SUMMARY_STEP', + metplus_configs=['ASCII2NC_TIME_SUMMARY_STEP']) + + self.add_met_config(name='', + data_type='string', + env_var_name='TIME_SUMMARY_WIDTH', + metplus_configs=['ASCII2NC_TIME_SUMMARY_WIDTH'], + extra_args={'remove_quotes': True}) + + self.add_met_config(name='', + data_type='list', + env_var_name='TIME_SUMMARY_GRIB_CODES', + metplus_configs=['ASCII2NC_TIME_SUMMARY_GRIB_CODES', + 'ASCII2NC_TIME_SUMMARY_GRIB_CODE'], + extra_args={'remove_quotes': True, + 'allow_empty': True}) + + self.add_met_config(name='', + data_type='list', + env_var_name='TIME_SUMMARY_VAR_NAMES', + metplus_configs=['ASCII2NC_TIME_SUMMARY_OBS_VAR', + 'ASCII2NC_TIME_SUMMARY_VAR_NAMES'], + extra_args={'allow_empty': True}) + + self.add_met_config(name='', + data_type='list', + env_var_name='TIME_SUMMARY_TYPES', + metplus_configs=['ASCII2NC_TIME_SUMMARY_TYPE', + 'ASCII2NC_TIME_SUMMARY_TYPES'], + extra_args={'allow_empty': True}) + + self.add_met_config(name='', + data_type='int', + env_var_name='TIME_SUMMARY_VALID_FREQ', + metplus_configs=['ASCII2NC_TIME_SUMMARY_VLD_FREQ', + 'ASCII2NC_TIME_SUMMARY_VALID_FREQ']) + + self.add_met_config(name='', + data_type='float', + env_var_name='TIME_SUMMARY_VALID_THRESH', + metplus_configs=['ASCII2NC_TIME_SUMMARY_VLD_THRESH', + 'ASCII2NC_TIME_SUMMARY_VALID_THRESH']) + def set_environment_variables(self, time_info): """!Set environment variables that will be read by the MET config file. Reformat as needed. Print list of variables that were set and their values. Args: @param time_info dictionary containing timing info from current run""" - # set environment variables needed for MET application + # set environment variables needed for legacy MET config file self.add_env_var('TIME_SUMMARY_FLAG', - self.c_dict['TIME_SUMMARY_FLAG']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_FLAG', '')) self.add_env_var('TIME_SUMMARY_RAW_DATA', - self.c_dict['TIME_SUMMARY_RAW_DATA']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_RAW_DATA', '')) self.add_env_var('TIME_SUMMARY_BEG', - self.c_dict['TIME_SUMMARY_BEG']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_BEG', '')) self.add_env_var('TIME_SUMMARY_END', - self.c_dict['TIME_SUMMARY_END']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_END', '')) self.add_env_var('TIME_SUMMARY_STEP', - self.c_dict['TIME_SUMMARY_STEP']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_STEP', '')) self.add_env_var('TIME_SUMMARY_WIDTH', - self.c_dict['TIME_SUMMARY_WIDTH']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_WIDTH', '')) self.add_env_var('TIME_SUMMARY_GRIB_CODES', - self.c_dict['TIME_SUMMARY_GRIB_CODES']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_GRIB_CODES', '').strip('[]')) self.add_env_var('TIME_SUMMARY_VAR_NAMES', - self.c_dict['TIME_SUMMARY_VAR_NAMES']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_VAR_NAMES', '').strip('[]')) self.add_env_var('TIME_SUMMARY_TYPES', - self.c_dict['TIME_SUMMARY_TYPES']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_TYPES', '').strip('[]')) self.add_env_var('TIME_SUMMARY_VALID_FREQ', - self.c_dict['TIME_SUMMARY_VALID_FREQ']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_VALID_FREQ', '')) self.add_env_var('TIME_SUMMARY_VALID_THRESH', - self.c_dict['TIME_SUMMARY_VALID_THRESH']) + self.env_var_dict.get('METPLUS_TIME_SUMMARY_VALID_THRESH', '')) # set user environment variables super().set_environment_variables(time_info) diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 22b1052721..d62707317b 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -20,9 +20,13 @@ from .command_runner import CommandRunner from ..util import met_util as util from ..util import do_string_sub, ti_calculate, get_seconds_from_string +from ..util import get_time_from_file from ..util import config_metplus -from ..util import METConfigInfo as met_config +from ..util import METConfig from ..util import MISSING_DATA_VALUE +from ..util import get_custom_string_list +from ..util import get_wrapped_met_config_file, add_met_config_item, format_met_config +from ..util.met_config import add_met_config_dict # pylint:disable=pointless-string-statement '''!@namespace CommandBuilder @@ -162,7 +166,6 @@ def check_for_unused_env_vars(self): "is not utilized in MET config file: " f"{config_file}") - def create_c_dict(self): c_dict = dict() # set skip if output exists to False for all wrappers @@ -176,8 +179,8 @@ def create_c_dict(self): if hasattr(self, 'app_name'): app_name = self.app_name - c_dict['CUSTOM_LOOP_LIST'] = util.get_custom_string_list(self.config, - app_name) + c_dict['CUSTOM_LOOP_LIST'] = get_custom_string_list(self.config, + app_name) c_dict['SKIP_TIMES'] = util.get_skip_times(self.config, app_name) @@ -280,20 +283,6 @@ def set_user_environment(self, time_info): **time_info) self.add_env_var(env_var, env_var_value) - @staticmethod - def format_regrid_to_grid(to_grid): - to_grid = to_grid.strip('"') - if not to_grid: - to_grid = 'NONE' - - # if NONE, FCST, or OBS force uppercase, otherwise add quotes - if to_grid.upper() in ['NONE', 'FCST', 'OBS']: - to_grid = to_grid.upper() - else: - to_grid = f'"{to_grid}"' - - return to_grid - def print_all_envs(self, print_copyable=True, print_each_item=True): """! Create list of log messages that output all environment variables that were set by this wrapper. @@ -319,7 +308,7 @@ def print_all_envs(self, print_copyable=True, print_each_item=True): def handle_window_once(self, input_list, default_val=0): """! Check and set window dictionary variables like - OBS_WINDOW_BEG or FCST_FILE_WINDW_END + OBS_WINDOW_BEG or FCST_FILE_WINDOW_END @param input_list list of config keys to check for value @param default_val value to use if none of the input keys found @@ -330,7 +319,7 @@ def handle_window_once(self, input_list, default_val=0): return default_val - def handle_obs_window_variables(self, c_dict): + def handle_obs_window_legacy(self, c_dict): """! Handle obs window config variables like OBS__WINDOW_[BEGIN/END]. Set c_dict values for begin and end to handle old method of setting env vars in MET config files, i.e. @@ -343,8 +332,6 @@ def handle_obs_window_variables(self, c_dict): ('END', 5400)] app = self.app_name.upper() - keys = [] - tmp_dict = {} for edge, default_val in edges: input_list = [f'OBS_{app}_WINDOW_{edge}', f'{app}_OBS_WINDOW_{edge}', @@ -353,16 +340,6 @@ def handle_obs_window_variables(self, c_dict): output_key = f'OBS_WINDOW_{edge}' value = self.handle_window_once(input_list, default_val) c_dict[output_key] = value - if edge == 'BEGIN': - edge = 'BEG' - tmp_dict[output_key] = f'{edge.lower()} = {value};' - # if something other than the default is used, add output key - # to the list of items to add to the env_var_dict value - if value != default_val: - keys.append(output_key) - - window_str = self.format_met_config_dict(tmp_dict, 'obs_window', keys) - self.env_var_dict['METPLUS_OBS_WINDOW_DICT'] = window_str def handle_file_window_variables(self, c_dict, dtypes=['FCST', 'OBS']): """! Handle all window config variables like @@ -795,7 +772,7 @@ def find_file_in_window(self, level, data_type, time_info, mandatory=True, # remove input data directory to get relative path rel_path = fullpath.replace(f'{data_dir}/', "") # extract time information from relative path using template - file_time_info = util.get_time_from_file(rel_path, template, self.logger) + file_time_info = get_time_from_file(rel_path, template, self.logger) if file_time_info is None: continue @@ -1448,297 +1425,6 @@ def set_time_dict_for_single_runtime(self): return time_info - def _get_config_or_default(self, mp_config_name, get_function, - default=None): - conf_value = '' - - # if no possible METplus config variables are not set - if mp_config_name is None: - # if no default defined, return without doing anything - if not default: - return None - - # if METplus config variable is set, read the value - else: - conf_value = get_function('config', - mp_config_name, - '') - - # if variable is not set and there is a default defined, set default - if not conf_value and default: - conf_value = default - - return conf_value - - def set_met_config_list(self, c_dict, mp_config, met_config_name, - c_dict_key=None, **kwargs): - """! Get list from METplus configuration file and format it to be passed - into a MET configuration file. Set c_dict item with formatted string. - Args: - @param c_dict configuration dictionary to set - @param mp_config_name METplus configuration variable name. Assumed to be - in the [config] section. Value can be a comma-separated list of items. - @param met_config name of MET configuration variable to set. Also used - to determine the key in c_dict to set (upper-case) - @param c_dict_key optional argument to specify c_dict key to store result. If - set to None (default) then use upper-case of met_config_name - @param allow_empty if True, if METplus config variable is set - but is an empty string, then set the c_dict value to an empty - list. If False, behavior is the same as when the variable is - not set at all, which is to not set anything for the c_dict - value - @param remove_quotes if True, output value without quotes. - Default value is False - @param default (Optional) if set, use this value as default - if config is not set - """ - mp_config_name = self.get_mp_config_name(mp_config) - conf_value = self._get_config_or_default( - mp_config_name, - get_function=self.config.getraw, - default=kwargs.get('default') - ) - if conf_value is None: - return - - # convert value from config to a list - conf_values = util.getlist(conf_value) - if conf_values or kwargs.get('allow_empty', False): - out_values = [] - for conf_value in conf_values: - remove_quotes = kwargs.get('remove_quotes', False) - # if not removing quotes, escape any quotes found in list items - if not remove_quotes: - conf_value = conf_value.replace('"', '\\"') - - conf_value = util.remove_quotes(conf_value) - if not remove_quotes: - conf_value = f'"{conf_value}"' - - out_values.append(conf_value) - out_value = f"[{', '.join(out_values)}]" - - if not c_dict_key: - c_key = met_config_name.upper() - else: - c_key = c_dict_key - - conf_value = f'{met_config_name} = {out_value};' - c_dict[c_key] = conf_value - - def set_met_config_string(self, c_dict, mp_config, met_config_name, - c_dict_key=None, **kwargs): - """! Get string from METplus configuration file and format it to be passed - into a MET configuration file. Set c_dict item with formatted string. - - @param c_dict configuration dictionary to set - @param mp_config METplus configuration variable name. Assumed to be - in the [config] section. Value can be a comma-separated list of items. - @param met_config_name name of MET configuration variable to set. Also used - to determine the key in c_dict to set (upper-case) - @param c_dict_key optional argument to specify c_dict key to store result. If - set to None (default) then use upper-case of met_config_name - @param remove_quotes if True, output value without quotes. - Default value is False - @param to_grid if True, format to_grid value - Default value is False - @param default (Optional) if set, use this value as default - if config is not set - """ - mp_config_name = self.get_mp_config_name(mp_config) - conf_value = self._get_config_or_default( - mp_config_name, - get_function=self.config.getraw, - default=kwargs.get('default') - ) - if not conf_value: - return - - conf_value = util.remove_quotes(conf_value) - # add quotes back if remote quotes is False - if not kwargs.get('remove_quotes'): - conf_value = f'"{conf_value}"' - - if kwargs.get('uppercase', False): - conf_value = conf_value.upper() - - if kwargs.get('to_grid', False): - conf_value = self.format_regrid_to_grid(conf_value) - - c_key = c_dict_key if c_dict_key else met_config_name.upper() - c_dict[c_key] = f'{met_config_name} = {conf_value};' - - def set_met_config_number(self, c_dict, num_type, mp_config, - met_config_name, c_dict_key=None, **kwargs): - """! Get integer from METplus configuration file and format it to be passed - into a MET configuration file. Set c_dict item with formatted string. - Args: - @param c_dict configuration dictionary to set - @param num_type type of number to get from config. If set to 'int', call - getint function. If not, call getfloat function. - @param mp_config METplus configuration variable name. Assumed to be - in the [config] section. Value can be a comma-separated list of items. - @param met_config_name name of MET configuration variable to set. Also used - to determine the key in c_dict to set (upper-case) if c_dict_key is None - @param c_dict_key optional argument to specify c_dict key to store result. If - set to None (default) then use upper-case of met_config_name - @param default (Optional) if set, use this value as default - if config is not set - """ - mp_config_name = self.get_mp_config_name(mp_config) - if mp_config_name is None: - return - - if num_type == 'int': - conf_value = self.config.getint('config', mp_config_name) - else: - conf_value = self.config.getfloat('config', mp_config_name) - - if conf_value is None: - self.isOK = False - elif conf_value != util.MISSING_DATA_VALUE: - if not c_dict_key: - c_key = met_config_name.upper() - else: - c_key = c_dict_key - - c_dict[c_key] = f"{met_config_name} = {str(conf_value)};" - - def set_met_config_int(self, c_dict, mp_config_name, met_config_name, - c_dict_key=None, **kwargs): - self.set_met_config_number(c_dict, 'int', - mp_config_name, - met_config_name, - c_dict_key=c_dict_key, - **kwargs) - - def set_met_config_float(self, c_dict, mp_config_name, - met_config_name, c_dict_key=None, **kwargs): - self.set_met_config_number(c_dict, 'float', - mp_config_name, - met_config_name, - c_dict_key=c_dict_key, - **kwargs) - - def set_met_config_thresh(self, c_dict, mp_config, met_config_name, - c_dict_key=None, **kwargs): - mp_config_name = self.get_mp_config_name(mp_config) - if mp_config_name is None: - return - - conf_value = self.config.getstr('config', mp_config_name, '') - if conf_value: - if util.get_threshold_via_regex(conf_value) is None: - self.log_error(f"Incorrectly formatted threshold: {mp_config_name}") - return - - if not c_dict_key: - c_key = met_config_name.upper() - else: - c_key = c_dict_key - - c_dict[c_key] = f"{met_config_name} = {str(conf_value)};" - - def set_met_config_bool(self, c_dict, mp_config, met_config_name, - c_dict_key=None, **kwargs): - """! Get boolean from METplus configuration file and format it to be - passed into a MET configuration file. Set c_dict item with boolean - value expressed as a string. - Args: - @param c_dict configuration dictionary to set - @param mp_config METplus configuration variable name. - Assumed to be in the [config] section. - @param met_config_name name of MET configuration variable to - set. Also used to determine the key in c_dict to set - (upper-case) - @param c_dict_key optional argument to specify c_dict key to - store result. If set to None (default) then use upper-case of - met_config_name - @param uppercase If true, set value to TRUE or FALSE - """ - mp_config_name = self.get_mp_config_name(mp_config) - if mp_config_name is None: - return - conf_value = self.config.getbool('config', mp_config_name, '') - if conf_value is None: - self.log_error(f'Invalid boolean value set for {mp_config_name}') - return - - # if not invalid but unset, return without setting c_dict with no error - if conf_value == '': - return - - conf_value = str(conf_value) - if kwargs.get('uppercase', True): - conf_value = conf_value.upper() - - if not c_dict_key: - c_key = met_config_name.upper() - else: - c_key = c_dict_key - - c_dict[c_key] = (f'{met_config_name} = ' - f'{util.remove_quotes(conf_value)};') - - def get_mp_config_name(self, mp_config): - """! Get first name of METplus config variable that is set. - - @param mp_config list of METplus config keys to check. Can also be a - single item - @returns Name of first METplus config name in list that is set in the - METplusConfig object. None if none keys in the list are set. - """ - if not isinstance(mp_config, list): - mp_configs = [mp_config] - else: - mp_configs = mp_config - - for mp_config_name in mp_configs: - if self.config.has_option('config', mp_config_name): - return mp_config_name - - return None - - @staticmethod - def format_met_config(data_type, c_dict, name, keys=None): - """! Return formatted variable named with any if they - are set to a value. If none of the items are set, return empty string - - @param data_type type of value to format - @param c_dict config dictionary to read values from - @param name name of dictionary to create - @param keys list of c_dict keys to use if they are set. If unset (None) - then read all keys from c_dict - @returns MET config formatted dictionary/list - if any items are set, or empty string if not - """ - values = [] - if keys is None: - keys = c_dict.keys() - - for key in keys: - value = c_dict.get(key) - if value: - values.append(str(value)) - - # if none of the keys are set to a value in dict, return empty string - if not values: - return '' - - output = ''.join(values) - # add curly braces if dictionary - if 'dict' in data_type: - output = f"{{{output}}}" - - # add square braces if list - if 'list' in data_type: - output = f"[{output}];" - - # if name is not empty, add variable name and equals sign - if name: - output = f'{name} = {output}' - return output - @staticmethod def format_met_config_dict(c_dict, name, keys=None): """! Return formatted dictionary named with any if they @@ -1751,59 +1437,36 @@ def format_met_config_dict(c_dict, name, keys=None): @returns MET config formatted dictionary if any items are set, or empty string if not """ - return CommandBuilder.format_met_config('dict', c_dict=c_dict, name=name, keys=keys) + return format_met_config('dict', c_dict=c_dict, name=name, keys=keys) def handle_regrid(self, c_dict, set_to_grid=True): - app_name_upper = self.app_name.upper() - - # dictionary to hold regrid values as they are read - tmp_dict = {} - + dict_items = {} if set_to_grid: - conf_value = ( - self.config.getstr('config', - f'{app_name_upper}_REGRID_TO_GRID', '') + dict_items['to_grid'] = ('string', 'to_grid') + + # handle legacy format of to_grid + self.add_met_config( + name='', + data_type='string', + env_var_name='REGRID_TO_GRID', + metplus_configs=[f'{self.app_name.upper()}_REGRID_TO_GRID'], + extra_args={'to_grid': True}, + output_dict=c_dict, ) - # set to_grid without formatting for backwards compatibility - formatted_to_grid = self.format_regrid_to_grid(conf_value) - c_dict['REGRID_TO_GRID'] = formatted_to_grid - - if conf_value: - tmp_dict['REGRID_TO_GRID'] = ( - f"to_grid = {formatted_to_grid};" - ) - - self.set_met_config_string(tmp_dict, - f'{app_name_upper}_REGRID_METHOD', - 'method', - c_dict_key='REGRID_METHOD', - remove_quotes=True) - - self.set_met_config_int(tmp_dict, - f'{app_name_upper}_REGRID_WIDTH', - 'width', - c_dict_key='REGRID_WIDTH') - - self.set_met_config_float(tmp_dict, - f'{app_name_upper}_REGRID_VLD_THRESH', - 'vld_thresh', - c_dict_key='REGRID_VLD_THRESH') - self.set_met_config_string(tmp_dict, - f'{app_name_upper}_REGRID_SHAPE', - 'shape', - c_dict_key='REGRID_SHAPE', - remove_quotes=True) - - regrid_string = self.format_met_config_dict(tmp_dict, - 'regrid', - ['REGRID_TO_GRID', - 'REGRID_METHOD', - 'REGRID_WIDTH', - 'REGRID_VLD_THRESH', - 'REGRID_SHAPE', - ]) - self.env_var_dict['METPLUS_REGRID_DICT'] = regrid_string + # set REGRID_TO_GRID to NONE if unset + regrid_value = c_dict.get('METPLUS_REGRID_TO_GRID', '') + if not regrid_value: + regrid_value = 'NONE' + c_dict['REGRID_TO_GRID'] = regrid_value + if 'METPLUS_REGRID_TO_GRID' in c_dict: + del c_dict['METPLUS_REGRID_TO_GRID'] + + dict_items['method'] = ('string', 'uppercase,remove_quotes') + dict_items['width'] = 'int' + dict_items['vld_thresh'] = 'float' + dict_items['shape'] = ('string', 'uppercase,remove_quotes') + self.add_met_config_dict('regrid', dict_items) def handle_description(self): """! Get description from config. If _DESC is set, use @@ -1859,29 +1522,6 @@ def get_output_prefix(self, time_info=None, set_env_vars=True): return output_prefix - def _parse_extra_args(self, extra): - """! Check string for extra option keywords and set them to True in - dictionary if they are found. Supports 'remove_quotes', 'uppercase' - and 'allow_empty' - - @param extra string to parse for keywords - @returns dictionary with extra args set if found in string - """ - extra_args = {} - if not extra: - return extra_args - - VALID_EXTRAS = ( - 'remove_quotes', - 'uppercase', - 'allow_empty', - 'to_grid', - ) - for extra_option in VALID_EXTRAS: - if extra_option in extra: - extra_args[extra_option] = True - return extra_args - def handle_climo_dict(self): """! Read climo mean/stdev variables with and set env_var_dict appropriately. Handle previous environment variables that are used @@ -1908,7 +1548,7 @@ def handle_climo_dict(self): # make sure _FILE_NAME is set from INPUT_TEMPLATE/DIR if used self.read_climo_file_name(climo_type) - self.handle_met_config_dict(dict_name, items) + self.add_met_config_dict(dict_name, items) # handle deprecated env vars CLIMO_MEAN_FILE and CLIMO_STDEV_FILE # that are used by pre v4.0.0 wrapped MET config files @@ -2021,41 +1661,24 @@ def handle_flags(self, flag_type): if not hasattr(self, f'{flag_type_upper}_FLAGS'): return - tmp_dict = {} - flag_list = [] + flag_info_dict = {} for flag in getattr(self, f'{flag_type_upper}_FLAGS'): - flag_name = f'{flag_type_upper}_FLAG_{flag.upper()}' - flag_list.append(flag_name) - self.set_met_config_string(tmp_dict, - f'{self.app_name.upper()}_{flag_name}', - flag, - c_dict_key=f'{flag_name}', - remove_quotes=True, - uppercase=True) - - flag_fmt = ( - self.format_met_config_dict(tmp_dict, - f'{flag_type_lower}_flag', - flag_list) - ) - self.env_var_dict[f'METPLUS_{flag_type_upper}_FLAG_DICT'] = flag_fmt + flag_info_dict[flag] = ('string', 'remove_quotes,uppercase') + + self.add_met_config_dict(f'{flag_type_lower}_flag', flag_info_dict) def handle_censor_val_and_thresh(self): """! Read {APP_NAME}_CENSOR_[VAL/THRESH] and set METPLUS_CENSOR_[VAL/THRESH] in self.env_var_dict so it can be referenced in a MET config file """ - self.set_met_config_list(self.env_var_dict, - f'{self.app_name.upper()}_CENSOR_THRESH', - 'censor_thresh', - c_dict_key='METPLUS_CENSOR_THRESH', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - f'{self.app_name.upper()}_CENSOR_VAL', - 'censor_val', - c_dict_key='METPLUS_CENSOR_VAL', - remove_quotes=True) + self.add_met_config(name='censor_thresh', + data_type='list', + extra_args={'remove_quotes': True}) + + self.add_met_config(name='censor_val', + data_type='list', + extra_args={'remove_quotes': True}) def get_env_var_value(self, env_var_name, read_dict=None, item_type=None): """! Read env var value, get text after the equals sign and remove the @@ -2086,7 +1709,7 @@ def handle_time_summary_dict(self): METPLUS_TIME_SUMMARY_DICT that is referenced in the wrapped MET config files. """ - self.handle_met_config_dict('time_summary', { + self.add_met_config_dict('time_summary', { 'flag': 'bool', 'raw_data': 'bool', 'beg': 'string', @@ -2102,105 +1725,6 @@ def handle_time_summary_dict(self): 'vld_thresh': ('float', None, None, ['TIME_SUMMARY_VALID_THRESH']), }) - def handle_time_summary_legacy(self, c_dict, remove_bracket_list=None): - """! Read METplusConfig variables for the MET config time_summary - dictionary and format values into environment variable - METPLUS_TIME_SUMMARY_DICT as well as other environment variables - that contain individuals items of the time_summary dictionary - that were referenced in wrapped MET config files prior to METplus 4.0. - Developer note: If we discontinue support for legacy wrapped MET - config files - - @param c_dict dictionary to store time_summary item values - @param remove_bracket_list (optional) list of items that need the - square brackets around the value removed because the legacy (pre 4.0) - wrapped MET config includes square braces around the environment - variable. - """ - tmp_dict = {} - app = self.app_name.upper() - self.set_met_config_bool(tmp_dict, - f'{app}_TIME_SUMMARY_FLAG', - 'flag', - 'TIME_SUMMARY_FLAG') - - self.set_met_config_bool(tmp_dict, - f'{app}_TIME_SUMMARY_RAW_DATA', - 'raw_data', - 'TIME_SUMMARY_RAW_DATA') - - self.set_met_config_string(tmp_dict, - f'{app}_TIME_SUMMARY_BEG', - 'beg', - 'TIME_SUMMARY_BEG') - - self.set_met_config_string(tmp_dict, - f'{app}_TIME_SUMMARY_END', - 'end', - 'TIME_SUMMARY_END') - - self.set_met_config_int(tmp_dict, - f'{app}_TIME_SUMMARY_STEP', - 'step', - 'TIME_SUMMARY_STEP') - - self.set_met_config_string(tmp_dict, - f'{app}_TIME_SUMMARY_WIDTH', - 'width', - 'TIME_SUMMARY_WIDTH', - remove_quotes=True) - - self.set_met_config_list(tmp_dict, - [f'{app}_TIME_SUMMARY_GRIB_CODES', - f'{app}_TIME_SUMMARY_GRIB_CODE'], - 'grib_code', - 'TIME_SUMMARY_GRIB_CODES', - remove_quotes=True, - allow_empty=True) - - self.set_met_config_list(tmp_dict, - [f'{app}_TIME_SUMMARY_OBS_VAR', - f'{app}_TIME_SUMMARY_VAR_NAMES'], - 'obs_var', - 'TIME_SUMMARY_VAR_NAMES', - allow_empty=True) - - self.set_met_config_list(tmp_dict, - [f'{app}_TIME_SUMMARY_TYPE', - f'{app}_TIME_SUMMARY_TYPES'], - 'type', - 'TIME_SUMMARY_TYPES', - allow_empty=True) - - self.set_met_config_int(tmp_dict, - [f'{app}_TIME_SUMMARY_VLD_FREQ', - f'{app}_TIME_SUMMARY_VALID_FREQ'], - 'vld_freq', - 'TIME_SUMMARY_VALID_FREQ') - - self.set_met_config_float(tmp_dict, - [f'{app}_TIME_SUMMARY_VLD_THRESH', - f'{app}_TIME_SUMMARY_VALID_THRESH'], - 'vld_thresh', - 'TIME_SUMMARY_VALID_THRESH') - - time_summary = self.format_met_config_dict(tmp_dict, - 'time_summary', - keys=None) - self.env_var_dict['METPLUS_TIME_SUMMARY_DICT'] = time_summary - - # set c_dict values to support old method of setting env vars - for key, value in tmp_dict.items(): - c_dict[key] = self.get_env_var_value(key, read_dict=tmp_dict) - - # remove brackets [] from lists - if not remove_bracket_list: - return - - for list_value in remove_bracket_list: - if c_dict.get(list_value): - c_dict[list_value] = c_dict[list_value].strip('[]') - def handle_mask(self, single_value=False, get_flags=False): """! Read mask dictionary values and set them into env_var_list @@ -2221,85 +1745,9 @@ def handle_mask(self, single_value=False, get_flags=False): items['grid_flag'] = ('string', 'remove_quotes,uppercase') items['poly_flag'] = ('string', 'remove_quotes,uppercase') - self.handle_met_config_dict('mask', items) + self.add_met_config_dict('mask', items) - def set_met_config_function(self, item_type): - """! Return function to use based on item type - - @param item_type type of MET config variable to obtain - Valid values: list, string, int, float, thresh, bool - @returns function to use or None if invalid type provided - """ - if item_type == 'int': - return self.set_met_config_int - elif item_type == 'string': - return self.set_met_config_string - elif item_type == 'list': - return self.set_met_config_list - elif item_type == 'float': - return self.set_met_config_float - elif item_type == 'thresh': - return self.set_met_config_thresh - elif item_type == 'bool': - return self.set_met_config_bool - else: - self.log_error("Invalid argument for item type: " - f"{item_type}") - return None - - def handle_met_config_item(self, item, output_dict=None, depth=0): - """! Reads info from METConfigInfo object, gets value from - METplusConfig, and formats it based on the specifications. Sets - value in output dictionary with key starting with METPLUS_. - - @param item METConfigInfo object to read and determine what to get - @param output_dict (optional) dictionary to save formatted output - If unset, use self.env_var_dict. - @param depth counter to check if item being processed is nested within - another variable or not. If depth is 0, it is a top level variable. - This is used internally by this function and shouldn't be supplied - outside of calls within this function. - """ - if output_dict is None: - output_dict = self.env_var_dict - - env_var_name = item.env_var_name.upper() - if not env_var_name.startswith('METPLUS_'): - env_var_name = f'METPLUS_{env_var_name}' - - # handle dictionary or dictionary list item - if 'dict' in item.data_type: - tmp_dict = {} - for child in item.children: - if not self.handle_met_config_item(child, tmp_dict, - depth=depth+1): - return False - - dict_string = self.format_met_config(item.data_type, - tmp_dict, - item.name, - keys=None) - - # if handling dict MET config that is not nested inside another - if not depth and item.data_type == 'dict': - env_var_name = f'{env_var_name}_DICT' - - output_dict[env_var_name] = dict_string - return True - - # handle non-dictionary item - set_met_config = self.set_met_config_function(item.data_type) - if not set_met_config: - return False - - set_met_config(output_dict, - item.metplus_configs, - item.name, - c_dict_key=env_var_name, - **item.extra_args) - return True - - def handle_met_config_dict(self, dict_name, items): + def add_met_config_dict(self, dict_name, items): """! Read config variables for MET config dictionary and set env_var_dict with formatted values @@ -2308,127 +1756,30 @@ def handle_met_config_dict(self, dict_name, items): dictionary and the value is info about the item (see parse_item_info function for more information) """ - dict_items = [] - - # config prefix i.e GRID_STAT_CLIMO_MEAN_ - metplus_prefix = f'{self.app_name}_{dict_name}_'.upper() - for name, item_info in items.items(): - data_type, extra, kids, nicknames = self.parse_item_info(item_info) - - # config name i.e. GRID_STAT_CLIMO_MEAN_FILE_NAME - metplus_name = f'{metplus_prefix}{name.upper()}' - - # change (n) to _N i.e. distance_map.beta_value(n) - metplus_name = metplus_name.replace('(N)', '_N') - metplus_configs = [] - - if 'dict' not in data_type: - children = None - # if variable ends with _BEG, read _BEGIN first - if metplus_name.endswith('BEG'): - metplus_configs.append(f'{metplus_name}IN') - - metplus_configs.append(metplus_name) - if nicknames: - for nickname in nicknames: - metplus_configs.append( - f'{self.app_name}_{nickname}'.upper() - ) - - # if dictionary, read get children from MET config - else: - children = [] - for kid_name, kid_info in kids.items(): - kid_upper = kid_name.upper() - kid_type, kid_extra, _, _ = self.parse_item_info(kid_info) - - metplus_configs.append(f'{metplus_name}_{kid_upper}') - metplus_configs.append(f'{metplus_prefix}{kid_upper}') - - kid_args = self._parse_extra_args(kid_extra) - child_item = self.get_met_config( - name=kid_name, - data_type=kid_type, - metplus_configs=metplus_configs.copy(), - extra_args=kid_args, - ) - children.append(child_item) - - # reset metplus config list for next kid - metplus_configs.clear() - - # set metplus_configs - metplus_configs = None - - extra_args = self._parse_extra_args(extra) - dict_item = ( - self.get_met_config( - name=name, - data_type=data_type, - metplus_configs=metplus_configs, - extra_args=extra_args, - children=children, - ) - ) - dict_items.append(dict_item) - - final_met_config = self.get_met_config( - name=dict_name, - data_type='dict', - children=dict_items, - ) - - return self.handle_met_config_item(final_met_config, self.env_var_dict) - - @staticmethod - def parse_item_info(item_info): - """! Parses info about a MET config dictionary item. The input can - be a single string that is the data type of the item. It can also be - a tuple containing 2 to 4 values. The additional values must be - supplied in order: - * extra: string of extra information about item, i.e. - 'remove_quotes', 'uppercase', or 'allow_empty' - * kids: dictionary describing child values (used only for dict items) - where the key is the name of the variable and the value is item info - for the child variable in the same format as item_info that is - parsed in this function - * nicknames: list of other METplus config variable name that can be - used to set a value. The app name i.e. GRID_STAT_ is prepended to - each nickname in the list. Used for backwards compatibility for - METplus config variables whose name does not match the MET config - variable name - - @param item_info string or tuple containing information about a - dictionary item - @returns tuple of data type, extra info, children, and nicknames or - None for each tuple value that is not set - """ - if isinstance(item_info, tuple): - data_type, *rest = item_info - else: - data_type = item_info - rest = [] - - extra = rest.pop(0) if rest else None - kids = rest.pop(0) if rest else None - nicknames = rest.pop(0) if rest else None + return_code = add_met_config_dict(config=self.config, + app_name=self.app_name, + output_dict=self.env_var_dict, + dict_name=dict_name, + items=items) + if not return_code: + self.isOK = False - return data_type, extra, kids, nicknames + return return_code - def handle_met_config_window(self, dict_name): + def add_met_config_window(self, dict_name): """! Handle a MET config window dictionary. It is assumed that the dictionary only contains 'beg' and 'end' entries that are integers. @param dict_name name of MET dictionary """ - self.handle_met_config_dict(dict_name, { + self.add_met_config_dict(dict_name, { 'beg': 'int', 'end': 'int', }) def add_met_config(self, **kwargs): - """! Create METConfigInfo object from arguments and process - @param kwargs key arguments that should match METConfigInfo + """! Create METConfig object from arguments and process + @param kwargs key arguments that should match METConfig arguments, which includes the following: @param name MET config variable name to set @param data_type type of variable to set, i.e. string, list, bool @@ -2444,17 +1795,10 @@ def add_met_config(self, **kwargs): kwargs['metplus_configs'] = [ f"{self.app_name}_{kwargs.get('name')}".upper() ] - item = met_config(**kwargs) - output_dict = kwargs.get('output_dict') - self.handle_met_config_item(item, output_dict) - - def get_met_config(self, **kwargs): - """! Get METConfigInfo object from arguments and return it - @param kwargs key arguments that should match METConfigInfo - arguments - @returns METConfigInfo object - """ - return met_config(**kwargs) + item = METConfig(**kwargs) + output_dict = kwargs.get('output_dict', self.env_var_dict) + if not add_met_config_item(self.config, item, output_dict): + self.isOK = False def get_config_file(self, default_config_file=None): """! Get the MET config file path for the wrapper from the @@ -2464,20 +1808,9 @@ def get_config_file(self, default_config_file=None): file found in parm/met_config to use if config file is not set @returns path to wrapped config file or None if no default is provided """ - config_name = f'{self.app_name.upper()}_CONFIG_FILE' - config_file = self.config.getraw('config', config_name, '') - if config_file: - return config_file - - if not default_config_file: - return None - - default_config_path = os.path.join(self.config.getdir('PARM_BASE'), - 'met_config', + return get_wrapped_met_config_file(self.config, + self.app_name, default_config_file) - self.logger.debug(f"{config_name} is not set. " - f"Using {default_config_path}") - return default_config_path def get_start_time_input_dict(self): """! Get the first run time specified in config. Used if only running diff --git a/metplus/wrappers/compare_gridded_wrapper.py b/metplus/wrappers/compare_gridded_wrapper.py index 244f95e99e..f90e7c0567 100755 --- a/metplus/wrappers/compare_gridded_wrapper.py +++ b/metplus/wrappers/compare_gridded_wrapper.py @@ -14,6 +14,7 @@ from ..util import met_util as util from ..util import do_string_sub, ti_calculate +from ..util import parse_var_list from . import CommandBuilder '''!@namespace CompareGriddedWrapper @@ -50,8 +51,13 @@ def create_c_dict(self): which config variables are used in the wrapper""" c_dict = super().create_c_dict() - self.set_met_config_string(self.env_var_dict, 'MODEL', 'model', 'METPLUS_MODEL') - self.set_met_config_string(self.env_var_dict, 'OBTYPE', 'obtype', 'METPLUS_OBTYPE') + self.add_met_config(name='model', + data_type='string', + metplus_configs=['MODEL']) + + self.add_met_config(name='obtype', + data_type='string', + metplus_configs=['OBTYPE']) # set old MET config items for backwards compatibility c_dict['MODEL_OLD'] = self.config.getstr('config', 'MODEL', 'FCST') @@ -88,13 +94,11 @@ def create_c_dict(self): # handle window variables [FCST/OBS]_[FILE_]_WINDOW_[BEGIN/END] self.handle_file_window_variables(c_dict) - self.set_met_config_string(self.env_var_dict, - f'{self.app_name.upper()}_OUTPUT_PREFIX', - 'output_prefix', - 'METPLUS_OUTPUT_PREFIX') + self.add_met_config(name='output_prefix', + data_type='string') - c_dict['VAR_LIST_TEMP'] = util.parse_var_list(self.config, - met_tool=self.app_name) + c_dict['VAR_LIST_TEMP'] = parse_var_list(self.config, + met_tool=self.app_name) return c_dict @@ -111,8 +115,7 @@ def set_environment_variables(self, time_info): self.add_env_var('MODEL', self.c_dict.get('MODEL_OLD', '')) self.add_env_var('OBTYPE', self.c_dict.get('OBTYPE_OLD', '')) self.add_env_var('REGRID_TO_GRID', - self.c_dict.get('REGRID_TO_GRID', - 'NONE')) + self.c_dict.get('REGRID_TO_GRID', 'NONE')) super().set_environment_variables(time_info) @@ -400,7 +403,7 @@ def get_command(self): return cmd def handle_climo_cdf_dict(self): - self.handle_met_config_dict('climo_cdf', { + self.add_met_config_dict('climo_cdf', { 'cdf_bins': ('float', None, None, ['CLIMO_CDF_BINS']), 'center_bins': 'bool', 'write_bins': 'bool', @@ -425,4 +428,4 @@ def handle_interp_dict(self, uses_field=False): if uses_field: items['field'] = ('string', 'remove_quotes') - self.handle_met_config_dict('interp', items) + self.add_met_config_dict('interp', items) diff --git a/metplus/wrappers/ensemble_stat_wrapper.py b/metplus/wrappers/ensemble_stat_wrapper.py index 2c532ec05f..0bc64cd76b 100755 --- a/metplus/wrappers/ensemble_stat_wrapper.py +++ b/metplus/wrappers/ensemble_stat_wrapper.py @@ -16,6 +16,7 @@ from ..util import met_util as util from . import CompareGriddedWrapper from ..util import do_string_sub +from ..util import parse_var_list """!@namespace EnsembleStatWrapper @brief Wraps the MET tool ensemble_stat to compare ensemble datasets @@ -220,43 +221,39 @@ def create_c_dict(self): c_dict['MET_OBS_ERR_TABLE'] = \ self.config.getstr('config', 'ENSEMBLE_STAT_MET_OBS_ERR_TABLE', '') - self.set_met_config_float(self.env_var_dict, - 'ENSEMBLE_STAT_ENS_VLD_THRESH', - 'vld_thresh', - 'METPLUS_ENS_VLD_THRESH') - - self.set_met_config_list(self.env_var_dict, - 'ENSEMBLE_STAT_ENS_OBS_THRESH', - 'obs_thresh', - 'METPLUS_ENS_OBS_THRESH', - remove_quotes=True) - - self.set_met_config_float(self.env_var_dict, - 'ENSEMBLE_STAT_ENS_SSVAR_BIN_SIZE', - 'ens_ssvar_bin_size', - 'METPLUS_ENS_SSVAR_BIN_SIZE') - self.set_met_config_float(self.env_var_dict, - 'ENSEMBLE_STAT_ENS_PHIST_BIN_SIZE', - 'ens_phist_bin_size', - 'METPLUS_ENS_PHIST_BIN_SIZE') + self.add_met_config(name='vld_thresh', + data_type='float', + env_var_name='METPLUS_ENS_VLD_THRESH', + metplus_configs=['ENSEMBLE_STAT_ENS_VLD_THRESH', + 'ENSEMBLE_STAT_VLD_THRESH', + 'ENSEMBLE_STAT_ENS_VALID_THRESH', + 'ENSEMBLE_STAT_VALID_THRESH', + ]) + + self.add_met_config(name='obs_thresh', + data_type='list', + env_var_name='METPLUS_ENS_OBS_THRESH', + metplus_configs=['ENSEMBLE_STAT_ENS_OBS_THRESH', + 'ENSEMBLE_STAT_OBS_THRESH'], + extra_args={'remove_quotes': True}) + + self.add_met_config(name='ens_ssvar_bin_size', + data_type='float') + + self.add_met_config(name='ens_phist_bin_size', + data_type='float') self.handle_nbrhd_prob_dict() - self.set_met_config_float(self.env_var_dict, - 'ENSEMBLE_STAT_ENS_THRESH', - 'ens_thresh', - 'METPLUS_ENS_THRESH') + self.add_met_config(name='ens_thresh', + data_type='float') - self.set_met_config_string(self.env_var_dict, - 'ENSEMBLE_STAT_DUPLICATE_FLAG', - 'duplicate_flag', - 'METPLUS_DUPLICATE_FLAG', - remove_quotes=True) + self.add_met_config(name='duplicate_flag', + data_type='string', + extra_args={'remove_quotes': True}) - self.set_met_config_bool(self.env_var_dict, - 'ENSEMBLE_STAT_SKIP_CONST', - 'skip_const', - 'METPLUS_SKIP_CONST') + self.add_met_config(name='skip_const', + data_type='bool') # set climo_cdf dictionary variables self.handle_climo_cdf_dict() @@ -270,39 +267,35 @@ def create_c_dict(self): self.handle_flags('OUTPUT') self.handle_flags('ENSEMBLE') - self.set_met_config_bool(self.env_var_dict, - 'ENSEMBLE_STAT_OBS_ERROR_FLAG', - 'flag', - 'METPLUS_OBS_ERROR_FLAG') - self.set_met_config_list(self.env_var_dict, - 'ENSEMBLE_STAT_MASK_GRID', - 'grid', - 'METPLUS_MASK_GRID', - allow_empty=True) - self.set_met_config_list(self.env_var_dict, - 'ENSEMBLE_STAT_CI_ALPHA', - 'ci_alpha', - 'METPLUS_CI_ALPHA', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - 'ENSEMBLE_STAT_CENSOR_THRESH', - 'censor_thresh', - 'METPLUS_CENSOR_THRESH', - remove_quotes=True) - self.set_met_config_list(self.env_var_dict, - 'ENSEMBLE_STAT_CENSOR_VAL', - 'censor_val', - 'METPLUS_CENSOR_VAL', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - 'ENSEMBLE_STAT_MESSAGE_TYPE', - 'message_type', - 'METPLUS_MESSAGE_TYPE', - allow_empty=True) - - self.handle_obs_window_variables(c_dict) + self.add_met_config(name='flag', + data_type='bool', + env_var_name='METPLUS_OBS_ERROR_FLAG', + metplus_configs=['ENSEMBLE_STAT_OBS_ERROR_FLAG']) + + self.add_met_config(name='grid', + data_type='list', + env_var_name='METPLUS_MASK_GRID', + metplus_configs=['ENSEMBLE_STAT_MASK_GRID'], + extra_args={'allow_empty': True}) + + self.add_met_config(name='ci_alpha', + data_type='list', + extra_args={'remove_quotes': True}) + + self.add_met_config(name='censor_thresh', + data_type='list', + extra_args={'remove_quotes': True}) + + self.add_met_config(name='censor_val', + data_type='list', + extra_args={'remove_quotes': True}) + + self.add_met_config(name='message_type', + data_type='list', + extra_args={'allow_empty': True}) + + self.add_met_config_window('obs_window') + self.handle_obs_window_legacy(c_dict) c_dict['MASK_POLY_TEMPLATE'] = self.read_mask_poly() @@ -329,14 +322,14 @@ def create_c_dict(self): c_dict['VAR_LIST_OPTIONAL'] = True # parse var list for ENS fields - c_dict['ENS_VAR_LIST_TEMP'] = util.parse_var_list( + c_dict['ENS_VAR_LIST_TEMP'] = parse_var_list( self.config, data_type='ENS', met_tool=self.app_name ) # parse optional var list for FCST and/or OBS fields - c_dict['VAR_LIST_TEMP'] = util.parse_var_list( + c_dict['VAR_LIST_TEMP'] = parse_var_list( self.config, met_tool=self.app_name ) @@ -344,7 +337,7 @@ def create_c_dict(self): return c_dict def handle_nmep_smooth_dict(self): - self.handle_met_config_dict('nmep_smooth', { + self.add_met_config_dict('nmep_smooth', { 'vld_thresh': 'float', 'shape': ('string', 'uppercase,remove_quotes'), 'gaussian_dx': 'float', @@ -357,7 +350,7 @@ def handle_nmep_smooth_dict(self): }) def handle_nbrhd_prob_dict(self): - self.handle_met_config_dict('nbrhd_prob', { + self.add_met_config_dict('nbrhd_prob', { 'width': ('list', 'remove_quotes'), 'shape': ('string', 'uppercase,remove_quotes'), 'vld_thresh': 'float', diff --git a/metplus/wrappers/extract_tiles_wrapper.py b/metplus/wrappers/extract_tiles_wrapper.py index f9ad6dcc8a..6dd37a9684 100755 --- a/metplus/wrappers/extract_tiles_wrapper.py +++ b/metplus/wrappers/extract_tiles_wrapper.py @@ -15,6 +15,7 @@ from ..util import met_util as util from ..util import do_string_sub, ti_calculate +from ..util import parse_var_list from .regrid_data_plane_wrapper import RegridDataPlaneWrapper from . import CommandBuilder @@ -152,8 +153,8 @@ def create_c_dict(self): c_dict['LON_ADJ'] = self.config.getfloat('config', 'EXTRACT_TILES_LON_ADJ') - c_dict['VAR_LIST_TEMP'] = util.parse_var_list(self.config, - met_tool=self.app_name) + c_dict['VAR_LIST_TEMP'] = parse_var_list(self.config, + met_tool=self.app_name) return c_dict def regrid_data_plane_init(self): diff --git a/metplus/wrappers/gen_ens_prod_wrapper.py b/metplus/wrappers/gen_ens_prod_wrapper.py index 3beaebc66e..e8011fb0bd 100755 --- a/metplus/wrappers/gen_ens_prod_wrapper.py +++ b/metplus/wrappers/gen_ens_prod_wrapper.py @@ -118,7 +118,7 @@ def create_c_dict(self): metplus_configs=['DESC', 'GEN_ENS_PROD_DESC'], ) - self.handle_met_config_dict('regrid', { + self.add_met_config_dict('regrid', { 'to_grid': ('string', 'to_grid'), 'method': ('string', 'uppercase,remove_quotes'), 'width': 'int', @@ -176,13 +176,13 @@ def create_c_dict(self): extra_args={'remove_quotes': True, 'uppercase': True}) - self.handle_met_config_dict('nbrhd_prob', { + self.add_met_config_dict('nbrhd_prob', { 'width': ('list', 'remove_quotes'), 'shape': ('string', 'uppercase,remove_quotes'), 'vld_thresh': 'float', }) - self.handle_met_config_dict('nmep_smooth', { + self.add_met_config_dict('nmep_smooth', { 'vld_thresh': 'float', 'shape': ('string', 'uppercase,remove_quotes'), 'gaussian_dx': 'float', diff --git a/metplus/wrappers/grid_diag_wrapper.py b/metplus/wrappers/grid_diag_wrapper.py index b17de1dfd7..d6d306f758 100755 --- a/metplus/wrappers/grid_diag_wrapper.py +++ b/metplus/wrappers/grid_diag_wrapper.py @@ -16,6 +16,7 @@ from ..util import time_util from . import RuntimeFreqWrapper from ..util import do_string_sub +from ..util import parse_var_list '''!@namespace GridDiagWrapper @brief Wraps the Grid-Diag tool @@ -78,9 +79,9 @@ def create_c_dict(self): self.handle_censor_val_and_thresh() - c_dict['VAR_LIST_TEMP'] = util.parse_var_list(self.config, - data_type='FCST', - met_tool=self.app_name) + c_dict['VAR_LIST_TEMP'] = parse_var_list(self.config, + data_type='FCST', + met_tool=self.app_name) c_dict['MASK_POLY_TEMPLATE'] = self.read_mask_poly() diff --git a/metplus/wrappers/grid_stat_wrapper.py b/metplus/wrappers/grid_stat_wrapper.py index c020ae2dd0..2ad1d011a2 100755 --- a/metplus/wrappers/grid_stat_wrapper.py +++ b/metplus/wrappers/grid_stat_wrapper.py @@ -160,25 +160,31 @@ def create_c_dict(self): c_dict['ALLOW_MULTIPLE_FILES'] = False + self.add_met_config(name='cov_thresh', + data_type='list', + env_var_name='METPLUS_NBRHD_COV_THRESH', + metplus_configs=[ + 'GRID_STAT_NEIGHBORHOOD_COV_THRESH' + ], + extra_args={'remove_quotes': True}) + + self.add_met_config(name='width', + data_type='list', + env_var_name='METPLUS_NBRHD_WIDTH', + metplus_configs=[ + 'GRID_STAT_NEIGHBORHOOD_WIDTH' + ], + extra_args={'remove_quotes': True}) + + self.add_met_config(name='shape', + data_type='string', + env_var_name='METPLUS_NBRHD_SHAPE', + metplus_configs=[ + 'GRID_STAT_NEIGHBORHOOD_SHAPE' + ], + extra_args={'remove_quotes': True}) - self.set_met_config_list(self.env_var_dict, - f'GRID_STAT_NEIGHBORHOOD_COV_THRESH', - 'cov_thresh', - 'METPLUS_NBRHD_COV_THRESH', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - f'GRID_STAT_NEIGHBORHOOD_WIDTH', - 'width', - 'METPLUS_NBRHD_WIDTH', - remove_quotes=True) - - self.set_met_config_string(self.env_var_dict, - 'GRID_STAT_NEIGHBORHOOD_SHAPE', - 'shape', - 'METPLUS_NBRHD_SHAPE', - remove_quotes=True) - + # handle legacy environment variables used by old MET configs c_dict['NEIGHBORHOOD_WIDTH'] = ( self.config.getstr('config', 'GRID_STAT_NEIGHBORHOOD_WIDTH', '1') @@ -232,7 +238,7 @@ def create_c_dict(self): data_type='float', metplus_configs=['GRID_STAT_HSS_EC_VALUE']) - self.handle_met_config_dict('distance_map', { + self.add_met_config_dict('distance_map', { 'baddeley_p': 'int', 'baddeley_max_dist': 'float', 'fom_alpha': 'float', @@ -262,8 +268,9 @@ def set_environment_variables(self, time_info): self.add_env_var('NEIGHBORHOOD_SHAPE', self.c_dict['NEIGHBORHOOD_SHAPE']) + cov_thresh = self.get_env_var_value('METPLUS_NBRHD_COV_THRESH') self.add_env_var('NEIGHBORHOOD_COV_THRESH', - self.c_dict.get('NBRHD_COV_THRESH', '')) + cov_thresh) self.add_env_var('VERIF_MASK', self.c_dict.get('VERIFICATION_MASK', '')) diff --git a/metplus/wrappers/ioda2nc_wrapper.py b/metplus/wrappers/ioda2nc_wrapper.py index 3fecb4a4b0..bfdd2e42df 100755 --- a/metplus/wrappers/ioda2nc_wrapper.py +++ b/metplus/wrappers/ioda2nc_wrapper.py @@ -86,10 +86,10 @@ def create_c_dict(self): self.add_met_config(name='message_type_group_map', data_type='list', extra_args={'remove_quotes': True}) self.add_met_config(name='station_id', data_type='list') - self.handle_met_config_window('obs_window') + self.add_met_config_window('obs_window') self.handle_mask(single_value=True) - self.handle_met_config_window('elevation_range') - self.handle_met_config_window('level_range') + self.add_met_config_window('elevation_range') + self.add_met_config_window('level_range') self.add_met_config(name='obs_var', data_type='list') self.add_met_config(name='obs_name_map', data_type='list', extra_args={'remove_quotes': True}) diff --git a/metplus/wrappers/make_plots_wrapper.py b/metplus/wrappers/make_plots_wrapper.py index f33419f7ea..b73d660028 100755 --- a/metplus/wrappers/make_plots_wrapper.py +++ b/metplus/wrappers/make_plots_wrapper.py @@ -19,6 +19,7 @@ import itertools from ..util import met_util as util +from ..util import parse_var_list from . import CommandBuilder # handle if module can't be loaded to run wrapper @@ -120,7 +121,7 @@ def create_c_dict(self): c_dict['LOOP_LIST_ITEMS'] = util.getlist( self.config.getstr('config', 'LOOP_LIST_ITEMS') ) - c_dict['VAR_LIST'] = util.parse_var_list(self.config) + c_dict['VAR_LIST'] = parse_var_list(self.config) c_dict['MODEL_LIST'] = util.getlist( self.config.getstr('config', 'MODEL_LIST', '') ) diff --git a/metplus/wrappers/mode_wrapper.py b/metplus/wrappers/mode_wrapper.py index 0fd39b5387..af4237c4d7 100755 --- a/metplus/wrappers/mode_wrapper.py +++ b/metplus/wrappers/mode_wrapper.py @@ -150,15 +150,11 @@ def create_c_dict(self): ) c_dict['ONCE_PER_FIELD'] = True - self.set_met_config_bool(self.env_var_dict, - 'MODE_QUILT', - 'quilt', - 'METPLUS_QUILT') + self.add_met_config(name='quilt', + data_type='bool') - self.set_met_config_float(self.env_var_dict, - 'MODE_GRID_RES', - 'grid_res', - 'METPLUS_GRID_RES') + self.add_met_config(name='grid_res', + data_type='float') # if MODE_GRID_RES is not set, then unset the default values defaults = self.DEFAULT_VALUES.copy() @@ -168,83 +164,124 @@ def create_c_dict(self): # read forecast and observation field variables for data_type in ['FCST', 'OBS']: - self.set_met_config_list( - self.env_var_dict, - [f'{data_type}_MODE_CONV_RADIUS', - f'MODE_{data_type}_CONV_RADIUS', - 'MODE_CONV_RADIUS'], - 'conv_radius', - f'METPLUS_{data_type}_CONV_RADIUS', - remove_quotes=True, - default=defaults.get(f'{data_type}_CONV_RADIUS'), + self.add_met_config( + name='conv_radius', + data_type='list', + env_var_name=f'METPLUS_{data_type}_CONV_RADIUS', + metplus_configs=[f'{data_type}_MODE_CONV_RADIUS', + f'MODE_{data_type}_CONV_RADIUS', + 'MODE_CONV_RADIUS' + ], + extra_args={ + 'remove_quotes': True, + 'default': defaults.get(f'{data_type}_CONV_RADIUS') + } ) - self.set_met_config_list(self.env_var_dict, - [f'{data_type}_MODE_CONV_THRESH', - f'MODE_{data_type}_CONV_THRESH', - 'MODE_CONV_THRESH'], - 'conv_thresh', - f'METPLUS_{data_type}_CONV_THRESH', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - [f'{data_type}_MODE_MERGE_THRESH', - f'MODE_{data_type}_MERGE_THRESH', - 'MODE_MERGE_THRESH'], - 'merge_thresh', - f'METPLUS_{data_type}_MERGE_THRESH', - remove_quotes=True) - - self.set_met_config_string(self.env_var_dict, - [f'{data_type}_MODE_MERGE_FLAG', - f'MODE_{data_type}_MERGE_FLAG', - 'MODE_MERGE_FLAG'], - 'merge_flag', - f'METPLUS_{data_type}_MERGE_FLAG', - remove_quotes=True, - uppercase=True) - - self.set_met_config_list(self.env_var_dict, - [f'{data_type}_MODE_FILTER_ATTR_NAME', - f'MODE_{data_type}_FILTER_ATTR_NAME', - 'MODE_FILTER_ATTR_NAME'], - 'filter_attr_name', - f'METPLUS_{data_type}_FILTER_ATTR_NAME') - - self.set_met_config_list(self.env_var_dict, - [f'{data_type}_MODE_FILTER_ATTR_THRESH', - f'MODE_{data_type}_FILTER_ATTR_THRESH', - 'MODE_FILTER_ATTR_THRESH'], - 'filter_attr_thresh', - f'METPLUS_{data_type}_FILTER_ATTR_THRESH', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - [f'{data_type}_MODE_CENSOR_THRESH', - f'MODE_{data_type}_CENSOR_THRESH', - 'MODE_CENSOR_THRESH'], - 'censor_thresh', - f'METPLUS_{data_type}_CENSOR_THRESH', - remove_quotes=True) - - self.set_met_config_list(self.env_var_dict, - [f'{data_type}_MODE_CENSOR_VAL', - f'MODE_{data_type}_CENSOR_VAL', - f'{data_type}_MODE_CENSOR_VALUE', - f'MODE_{data_type}_CENSOR_VALUE', - 'MODE_CENSOR_VAL', - 'MODE_CENSOR_VALUE'], - 'censor_val', - f'METPLUS_{data_type}_CENSOR_VAL', - remove_quotes=True) - - self.set_met_config_float(self.env_var_dict, - [f'{data_type}_MODE_VLD_THRESH', - f'{data_type}_MODE_VALID_THRESH', - f'MODE_{data_type}_VLD_THRESH', - f'MODE_{data_type}_VALID_THRESH'], - 'vld_thresh', - f'METPLUS_{data_type}_VLD_THRESH') + self.add_met_config( + name='conv_thresh', + data_type='list', + env_var_name=f'METPLUS_{data_type}_CONV_THRESH', + metplus_configs=[f'{data_type}_MODE_CONV_THRESH', + f'MODE_{data_type}_CONV_THRESH', + 'MODE_CONV_THRESH' + ], + extra_args={ + 'remove_quotes': True, + } + ) + + self.add_met_config( + name='merge_thresh', + data_type='list', + env_var_name=f'METPLUS_{data_type}_MERGE_THRESH', + metplus_configs=[f'{data_type}_MODE_MERGE_THRESH', + f'MODE_{data_type}_MERGE_THRESH', + 'MODE_MERGE_THRESH' + ], + extra_args={ + 'remove_quotes': True, + } + ) + + self.add_met_config( + name='merge_flag', + data_type='string', + env_var_name=f'METPLUS_{data_type}_MERGE_FLAG', + metplus_configs=[f'{data_type}_MODE_MERGE_FLAG', + f'MODE_{data_type}_MERGE_FLAG', + 'MODE_MERGE_FLAG' + ], + extra_args={ + 'remove_quotes': True, + 'uppercase': True, + } + ) + + self.add_met_config( + name='filter_attr_name', + data_type='list', + env_var_name=f'METPLUS_{data_type}_FILTER_ATTR_NAME', + metplus_configs=[f'{data_type}_MODE_FILTER_ATTR_NAME', + f'MODE_{data_type}_FILTER_ATTR_NAME', + 'MODE_FILTER_ATTR_NAME' + ], + ) + + self.add_met_config( + name='filter_attr_thresh', + data_type='list', + env_var_name=f'METPLUS_{data_type}_FILTER_ATTR_THRESH', + metplus_configs=[f'{data_type}_MODE_FILTER_ATTR_THRESH', + f'MODE_{data_type}_FILTER_ATTR_THRESH', + 'MODE_FILTER_ATTR_THRESH' + ], + extra_args={ + 'remove_quotes': True, + } + ) + + self.add_met_config( + name='censor_thresh', + data_type='list', + env_var_name=f'METPLUS_{data_type}_CENSOR_THRESH', + metplus_configs=[f'{data_type}_MODE_CENSOR_THRESH', + f'MODE_{data_type}_CENSOR_THRESH', + 'MODE_CENSOR_THRESH' + ], + extra_args={ + 'remove_quotes': True, + } + ) + + self.add_met_config( + name='censor_val', + data_type='list', + env_var_name=f'METPLUS_{data_type}_CENSOR_VAL', + metplus_configs=[f'{data_type}_MODE_CENSOR_VAL', + f'MODE_{data_type}_CENSOR_VAL', + f'{data_type}_MODE_CENSOR_VALUE', + f'MODE_{data_type}_CENSOR_VALUE', + 'MODE_CENSOR_VAL', + 'MODE_CENSOR_VALUE', + ], + extra_args={ + 'remove_quotes': True, + } + ) + + self.add_met_config( + name='vld_thresh', + data_type='float', + env_var_name=f'METPLUS_{data_type}_VLD_THRESH', + metplus_configs=[f'{data_type}_MODE_VLD_THRESH', + f'MODE_{data_type}_VLD_THRESH', + f'{data_type}_MODE_VALID_THRESH', + f'MODE_{data_type}_VALID_THRESH', + 'MODE_VLD_THRESH', + 'MODE_VALID_THRESH' + ], + ) # set c_dict values for old method of setting env vars for name in ['CONV_RADIUS', @@ -254,14 +291,16 @@ def create_c_dict(self): value = self.get_env_var_value(f'METPLUS_{data_type}_{name}') c_dict[f'{data_type}_{name}'] = value - self.set_met_config_string(self.env_var_dict, - ['MODE_MATCH_FLAG'], - 'match_flag', - 'METPLUS_MATCH_FLAG', - remove_quotes=True, - uppercase=True) + self.add_met_config( + name='match_flag', + data_type='string', + extra_args={ + 'remove_quotes': True, + 'uppercase': True, + } + ) - self.handle_met_config_dict('weight', self.WEIGHTS) + self.add_met_config_dict('weight', self.WEIGHTS) self.handle_flags('nc_pairs') self.add_met_config(name='total_interest_thresh', diff --git a/metplus/wrappers/mtd_wrapper.py b/metplus/wrappers/mtd_wrapper.py index 328824ecbf..6bf2bc19be 100755 --- a/metplus/wrappers/mtd_wrapper.py +++ b/metplus/wrappers/mtd_wrapper.py @@ -15,6 +15,7 @@ from ..util import met_util as util from ..util import time_util from ..util import do_string_sub +from ..util import parse_var_list from . import CompareGriddedWrapper class MTDWrapper(CompareGriddedWrapper): @@ -66,8 +67,8 @@ def create_c_dict(self): c_dict['CONFIG_FILE'] = self.get_config_file('MTDConfig_wrapped') # new method of reading/setting MET config values - self.set_met_config_int(self.env_var_dict, 'MTD_MIN_VOLUME', - 'min_volume', 'METPLUS_MIN_VOLUME') + self.add_met_config(name='min_volume', + data_type='int') # old approach to reading/setting MET config values c_dict['MIN_VOLUME'] = self.config.getstr('config', @@ -124,9 +125,9 @@ def create_c_dict(self): self.read_field_values(c_dict, 'OBS', 'OBS') c_dict['VAR_LIST_TEMP'] = ( - util.parse_var_list(self.config, - data_type=c_dict.get('SINGLE_DATA_SRC'), - met_tool=self.app_name) + parse_var_list(self.config, + data_type=c_dict.get('SINGLE_DATA_SRC'), + met_tool=self.app_name) ) return c_dict @@ -137,17 +138,17 @@ def read_field_values(self, c_dict, read_type, write_type): self.config.getstr('config', f'{read_type}_MTD_INPUT_DATATYPE', '') ) - self.set_met_config_int(self.env_var_dict, - [f'{read_type}_MTD_CONV_RADIUS', - 'MTD_CONV_RADIUS'], - 'conv_radius', - f'METPLUS_{write_type}_CONV_RADIUS') - - self.set_met_config_thresh(self.env_var_dict, - [f'{read_type}_MTD_CONV_THRESH', - 'MTD_CONV_THRESH'], - 'conv_thresh', - f'METPLUS_{write_type}_CONV_THRESH') + self.add_met_config(name='conv_radius', + data_type='int', + env_var_name=f'METPLUS_{write_type}_CONV_RADIUS', + metplus_configs=[f'{read_type}_MTD_CONV_RADIUS', + 'MTD_CONV_RADIUS']) + + self.add_met_config(name='conv_thresh', + data_type='thresh', + env_var_name=f'METPLUS_{write_type}_CONV_THRESH', + metplus_configs=[f'{read_type}_MTD_CONV_THRESH', + 'MTD_CONV_THRESH']) # support old method of setting env vars conf_value = ( diff --git a/metplus/wrappers/pb2nc_wrapper.py b/metplus/wrappers/pb2nc_wrapper.py index 891fc367bc..5e8622a2bd 100755 --- a/metplus/wrappers/pb2nc_wrapper.py +++ b/metplus/wrappers/pb2nc_wrapper.py @@ -91,27 +91,55 @@ def create_c_dict(self): # get the MET config file path or use default c_dict['CONFIG_FILE'] = self.get_config_file('PB2NCConfig_wrapped') - self.set_met_config_list(self.env_var_dict, - 'PB2NC_MESSAGE_TYPE', - 'message_type', - 'METPLUS_MESSAGE_TYPE',) + self.add_met_config(name='message_type', + data_type='list') - self.set_met_config_list(self.env_var_dict, - 'PB2NC_STATION_ID', - 'station_id', - 'METPLUS_STATION_ID',) + self.add_met_config(name='station_id', + data_type='list') - self.handle_obs_window_variables(c_dict) + self.add_met_config_window('obs_window') + self.handle_obs_window_legacy(c_dict) self.handle_mask(single_value=True) - self.set_met_config_list(self.env_var_dict, - 'PB2NC_OBS_BUFR_VAR_LIST', - 'obs_bufr_var', - 'METPLUS_OBS_BUFR_VAR', - allow_empty=True) + self.add_met_config(name='obs_bufr_var', + data_type='list', + metplus_configs=['PB2NC_OBS_BUFR_VAR_LIST', + 'PB2NC_OBS_BUFR_VAR'], + extra_args={'allow_empty': True}) + + #self.handle_time_summary_legacy(c_dict) + self.handle_time_summary_dict() + + # handle legacy time summary variables + self.add_met_config(name='', + data_type='bool', + env_var_name='TIME_SUMMARY_FLAG', + metplus_configs=['PB2NC_TIME_SUMMARY_FLAG']) + + self.add_met_config(name='', + data_type='string', + env_var_name='TIME_SUMMARY_BEG', + metplus_configs=['PB2NC_TIME_SUMMARY_BEG']) + + self.add_met_config(name='', + data_type='string', + env_var_name='TIME_SUMMARY_END', + metplus_configs=['PB2NC_TIME_SUMMARY_END']) + + self.add_met_config(name='', + data_type='list', + env_var_name='TIME_SUMMARY_VAR_NAMES', + metplus_configs=['PB2NC_TIME_SUMMARY_OBS_VAR', + 'PB2NC_TIME_SUMMARY_VAR_NAMES'], + extra_args={'allow_empty': True}) - self.handle_time_summary_legacy(c_dict) + self.add_met_config(name='', + data_type='list', + env_var_name='TIME_SUMMARY_TYPES', + metplus_configs=['PB2NC_TIME_SUMMARY_TYPE', + 'PB2NC_TIME_SUMMARY_TYPES'], + extra_args={'allow_empty': True}) self.handle_file_window_variables(c_dict, dtypes=['OBS']) @@ -147,22 +175,7 @@ def create_c_dict(self): extra_args={'remove_quotes': True}) # get level_range beg and end - level_range_items = [] - level_range_items.append( - self.get_met_config(name='beg', - data_type='int', - metplus_configs=['PB2NC_LEVEL_RANGE_BEG', - 'PB2NC_LEVEL_RANGE_BEGIN']) - ) - level_range_items.append( - self.get_met_config(name='end', - data_type='int', - metplus_configs=['PB2NC_LEVEL_RANGE_END']) - ) - - self.add_met_config(name='level_range', - data_type='dict', - children=level_range_items) + self.add_met_config_window('level_range') self.add_met_config(name='level_category', data_type='list', @@ -173,7 +186,6 @@ def create_c_dict(self): data_type='int', metplus_configs=['PB2NC_QUALITY_MARK_THRESH']) - return c_dict def set_environment_variables(self, time_info): @@ -196,16 +208,10 @@ def set_environment_variables(self, time_info): self.add_env_var("OBS_BUFR_VAR_LIST", self.c_dict.get('BUFR_VAR_LIST', '')) - self.add_env_var('TIME_SUMMARY_FLAG', - self.c_dict.get('TIME_SUMMARY_FLAG', '')) - self.add_env_var('TIME_SUMMARY_BEG', - self.c_dict.get('TIME_SUMMARY_BEG', '')) - self.add_env_var('TIME_SUMMARY_END', - self.c_dict.get('TIME_SUMMARY_END', '')) - self.add_env_var('TIME_SUMMARY_VAR_NAMES', - self.c_dict.get('TIME_SUMMARY_VAR_NAMES', '')) - self.add_env_var('TIME_SUMMARY_TYPES', - self.c_dict.get('TIME_SUMMARY_TYPES', '')) + for item in ['FLAG', 'BEG', 'END', 'VAR_NAMES', 'TYPES']: + ts_item = f'TIME_SUMMARY_{item}' + self.add_env_var(f'{ts_item}', + self.env_var_dict.get(f'METPLUS_{ts_item}', '')) super().set_environment_variables(time_info) diff --git a/metplus/wrappers/pcp_combine_wrapper.py b/metplus/wrappers/pcp_combine_wrapper.py index 9d90a494ae..d34eaa609b 100755 --- a/metplus/wrappers/pcp_combine_wrapper.py +++ b/metplus/wrappers/pcp_combine_wrapper.py @@ -12,6 +12,7 @@ from ..util import get_seconds_from_string, ti_get_lead_string, ti_calculate from ..util import get_relativedelta, ti_get_seconds_from_relativedelta from ..util import time_string_to_met_time, seconds_to_met_time +from ..util import parse_var_list from . import ReformatGriddedWrapper '''!@namespace PCPCombineWrapper @@ -56,14 +57,14 @@ def create_c_dict(self): if fcst_run: c_dict = self.set_fcst_or_obs_dict_items('FCST', c_dict) - c_dict['VAR_LIST_FCST'] = util.parse_var_list( + c_dict['VAR_LIST_FCST'] = parse_var_list( self.config, data_type='FCST', met_tool=self.app_name ) if obs_run: c_dict = self.set_fcst_or_obs_dict_items('OBS', c_dict) - c_dict['VAR_LIST_OBS'] = util.parse_var_list( + c_dict['VAR_LIST_OBS'] = parse_var_list( self.config, data_type='OBS', met_tool=self.app_name diff --git a/metplus/wrappers/point_stat_wrapper.py b/metplus/wrappers/point_stat_wrapper.py index a21df7caad..02c97debee 100755 --- a/metplus/wrappers/point_stat_wrapper.py +++ b/metplus/wrappers/point_stat_wrapper.py @@ -140,34 +140,32 @@ def create_c_dict(self): # get the MET config file path or use default c_dict['CONFIG_FILE'] = self.get_config_file('PointStatConfig_wrapped') - self.handle_obs_window_variables(c_dict) - - self.set_met_config_list(self.env_var_dict, - ['POINT_STAT_MASK_GRID', - 'POINT_STAT_GRID'], - 'grid', - 'METPLUS_MASK_GRID', - allow_empty=True) - - self.set_met_config_list(self.env_var_dict, - ['POINT_STAT_MASK_POLY', - 'POINT_STAT_POLY'], - 'poly', - 'METPLUS_MASK_POLY', - allow_empty=True) - - self.set_met_config_list(self.env_var_dict, - ['POINT_STAT_MASK_SID', - 'POINT_STAT_STATION_ID'], - 'sid', - 'METPLUS_MASK_SID', - allow_empty=True) - - - self.set_met_config_list(self.env_var_dict, - 'POINT_STAT_MESSAGE_TYPE', - 'message_type', - 'METPLUS_MESSAGE_TYPE',) + self.add_met_config_window('obs_window') + self.handle_obs_window_legacy(c_dict) + + self.add_met_config(name='grid', + data_type='list', + env_var_name='METPLUS_MASK_GRID', + metplus_configs=['POINT_STAT_MASK_GRID', + 'POINT_STAT_GRID'], + extra_args={'allow_empty': True}) + + self.add_met_config(name='poly', + data_type='list', + env_var_name='METPLUS_MASK_POLY', + metplus_configs=['POINT_STAT_MASK_POLY', + 'POINT_STAT_POLY'], + extra_args={'allow_empty': True}) + + self.add_met_config(name='sid', + data_type='list', + env_var_name='METPLUS_MASK_SID', + metplus_configs=['POINT_STAT_MASK_SID', + 'POINT_STAT_STATION_ID'], + extra_args={'allow_empty': True}) + + self.add_met_config(name='message_type', + data_type='list') self.handle_climo_cdf_dict() diff --git a/metplus/wrappers/regrid_data_plane_wrapper.py b/metplus/wrappers/regrid_data_plane_wrapper.py index b3f0711fcc..a3d544a0db 100755 --- a/metplus/wrappers/regrid_data_plane_wrapper.py +++ b/metplus/wrappers/regrid_data_plane_wrapper.py @@ -15,6 +15,8 @@ from ..util import met_util as util from ..util import time_util from ..util import do_string_sub +from ..util import parse_var_list +from ..util import get_process_list from . import ReformatGriddedWrapper # pylint:disable=pointless-string-statement @@ -106,7 +108,7 @@ def create_c_dict(self): self.log_error("FCST_REGRID_DATA_PLANE_OUTPUT_TEMPLATE must be set if " "FCST_REGRID_DATA_PLANE_RUN is True") - c_dict['VAR_LIST_FCST'] = util.parse_var_list( + c_dict['VAR_LIST_FCST'] = parse_var_list( self.config, data_type='FCST', met_tool=self.app_name @@ -129,7 +131,7 @@ def create_c_dict(self): self.log_error("OBS_REGRID_DATA_PLANE_OUTPUT_TEMPLATE must be set if " "OBS_REGRID_DATA_PLANE_RUN is True") - c_dict['VAR_LIST_OBS'] = util.parse_var_list( + c_dict['VAR_LIST_OBS'] = parse_var_list( self.config, data_type='OBS', met_tool=self.app_name @@ -155,7 +157,7 @@ def create_c_dict(self): # only check if VERIFICATION_GRID is set if running the tool from the process list # RegridDataPlane can be called from other tools like CustomIngest, which sets the # verification grid itself - if 'RegridDataPlane' in util.get_process_list(self.config): + if 'RegridDataPlane' in get_process_list(self.config): if not c_dict['VERIFICATION_GRID']: self.log_error("REGRID_DATA_PLANE_VERIF_GRID must be set.") diff --git a/metplus/wrappers/series_analysis_wrapper.py b/metplus/wrappers/series_analysis_wrapper.py index 972fa1c376..429b139c39 100755 --- a/metplus/wrappers/series_analysis_wrapper.py +++ b/metplus/wrappers/series_analysis_wrapper.py @@ -27,6 +27,7 @@ from ..util import get_lead_sequence, get_lead_sequence_groups, set_input_dict from ..util import ti_get_hours_from_lead, ti_get_seconds_from_lead from ..util import ti_get_lead_string +from ..util import parse_var_list from .plot_data_plane_wrapper import PlotDataPlaneWrapper from . import RuntimeFreqWrapper @@ -86,14 +87,13 @@ def create_c_dict(self): c_dict['VERBOSITY']) ) - self.set_met_config_string(self.env_var_dict, - 'MODEL', - 'model', - 'METPLUS_MODEL') - self.set_met_config_string(self.env_var_dict, - 'OBTYPE', - 'obtype', - 'METPLUS_OBTYPE') + self.add_met_config(name='model', + data_type='string', + metplus_configs=['MODEL']) + + self.add_met_config(name='obtype', + data_type='string', + metplus_configs=['OBTYPE']) # handle old format of MODEL and OBTYPE c_dict['MODEL'] = self.config.getstr('config', 'MODEL', 'WRF') @@ -103,22 +103,18 @@ def create_c_dict(self): self.handle_regrid(c_dict) - self.set_met_config_list(self.env_var_dict, - 'SERIES_ANALYSIS_CAT_THRESH', - 'cat_thresh', - 'METPLUS_CAT_THRESH', - remove_quotes=True) + self.add_met_config(name='cat_thresh', + data_type='list', + extra_args={'remove_quotes': True}) - self.set_met_config_float(self.env_var_dict, - 'SERIES_ANALYSIS_VLD_THRESH', - 'vld_thresh', - 'METPLUS_VLD_THRESH') + self.add_met_config(name='vld_thresh', + data_type='float', + metplus_configs=['SERIES_ANALYSIS_VLD_THRESH', + 'SERIES_ANALYSIS_VALID_THRESH',]) - self.set_met_config_string(self.env_var_dict, - 'SERIES_ANALYSIS_BLOCK_SIZE', - 'block_size', - 'METPLUS_BLOCK_SIZE', - remove_quotes=True) + self.add_met_config(name='block_size', + data_type='string', + extra_args={'remove_quotes': True}) # get stat list to loop over c_dict['STAT_LIST'] = util.getlist( @@ -130,16 +126,18 @@ def create_c_dict(self): self.log_error("Must set SERIES_ANALYSIS_STAT_LIST to run.") # set stat list to set output_stats.cnt in MET config file - self.set_met_config_list(self.env_var_dict, - 'SERIES_ANALYSIS_STAT_LIST', - 'cnt', - 'METPLUS_STAT_LIST') + self.add_met_config(name='cnt', + data_type='list', + env_var_name='METPLUS_STAT_LIST', + metplus_configs=['SERIES_ANALYSIS_STAT_LIST', + 'SERIES_ANALYSIS_CNT']) # set cts list to set output_stats.cts in MET config file - self.set_met_config_list(self.env_var_dict, - 'SERIES_ANALYSIS_CTS_LIST', - 'cts', - 'METPLUS_CTS_LIST') + self.add_met_config(name='cts', + data_type='list', + env_var_name='METPLUS_CTS_LIST', + metplus_configs=['SERIES_ANALYSIS_CTS_LIST', + 'SERIES_ANALYSIS_CTS']) c_dict['PAIRED'] = self.config.getbool('config', 'SERIES_ANALYSIS_IS_PAIRED', @@ -233,8 +231,8 @@ def create_c_dict(self): False) ) - c_dict['VAR_LIST_TEMP'] = util.parse_var_list(self.config, - met_tool=self.app_name) + c_dict['VAR_LIST_TEMP'] = parse_var_list(self.config, + met_tool=self.app_name) if not c_dict['VAR_LIST_TEMP']: self.log_error("No fields specified. Please set " "[FCST/OBS]_VAR_[NAME/LEVELS]") @@ -444,7 +442,7 @@ def get_storm_list(self, time_info): # Now that we have the filter filename for the init time, let's # extract all the storm ids in this filter file. - storm_list = util.get_storm_ids(filter_file) + storm_list = util.get_storms(filter_file, id_only=True) if not storm_list: # No storms for this init time, check next init time in list self.logger.debug("No storms found for current runtime") diff --git a/metplus/wrappers/stat_analysis_wrapper.py b/metplus/wrappers/stat_analysis_wrapper.py index 1d8289aaa2..44fabeb66e 100755 --- a/metplus/wrappers/stat_analysis_wrapper.py +++ b/metplus/wrappers/stat_analysis_wrapper.py @@ -19,7 +19,8 @@ import itertools from ..util import met_util as util -from ..util import do_string_sub +from ..util import do_string_sub, find_indices_in_config_section +from ..util import parse_var_list from . import CommandBuilder class StatAnalysisWrapper(CommandBuilder): @@ -215,7 +216,7 @@ def create_c_dict(self): if not self.MakePlotsWrapper.isOK: self.log_error("MakePlotsWrapper was not initialized correctly.") - c_dict['VAR_LIST'] = util.parse_var_list(self.config) + c_dict['VAR_LIST'] = parse_var_list(self.config) c_dict['MODEL_INFO_LIST'] = self.parse_model_info() if not c_dict['MODEL_LIST'] and c_dict['MODEL_INFO_LIST']: @@ -1229,9 +1230,9 @@ def parse_model_info(self): """ model_info_list = [] model_indices = list( - util.find_indices_in_config_section(r'MODEL(\d+)$', - self.config, - index_index=1).keys() + find_indices_in_config_section(r'MODEL(\d+)$', + self.config, + index_index=1).keys() ) for m in model_indices: model_name = self.config.getstr('config', f'MODEL{m}') diff --git a/metplus/wrappers/tc_gen_wrapper.py b/metplus/wrappers/tc_gen_wrapper.py index 94c6a0596e..91168a0e96 100755 --- a/metplus/wrappers/tc_gen_wrapper.py +++ b/metplus/wrappers/tc_gen_wrapper.py @@ -148,16 +148,16 @@ def create_c_dict(self): data_type='int', metplus_configs=['TC_GEN_VALID_FREQUENCY', 'TC_GEN_VALID_FREQ']) - self.handle_met_config_window('fcst_hr_window') + self.add_met_config_window('fcst_hr_window') self.add_met_config(name='min_duration', data_type='int', metplus_configs=['TC_GEN_MIN_DURATION']) - self.handle_met_config_dict('fcst_genesis', { + self.add_met_config_dict('fcst_genesis', { 'vmax_thresh': 'thresh', 'mslp_thresh': 'thresh', }) - self.handle_met_config_dict('best_genesis', { + self.add_met_config_dict('best_genesis', { 'technique': 'string', 'category': 'list', 'vmax_thresh': 'thresh', @@ -220,8 +220,8 @@ def create_c_dict(self): self.add_met_config(name='dev_hit_radius', data_type='int', metplus_configs=['TC_GEN_DEV_HIT_RADIUS']) - self.handle_met_config_window('dev_hit_window') - self.handle_met_config_window('ops_hit_window') + self.add_met_config_window('dev_hit_window') + self.add_met_config_window('ops_hit_window') self.add_met_config(name='discard_init_post_genesis_flag', data_type='bool', metplus_configs=[ @@ -260,7 +260,7 @@ def create_c_dict(self): data_type='bool', metplus_configs=['TC_GEN_GENESIS_MATCH_POINT_TO_TRACK'] ) - self.handle_met_config_window('genesis_match_window') + self.add_met_config_window('genesis_match_window') # get INPUT_TIME_DICT values since wrapper only runs # once (doesn't look over time) diff --git a/metplus/wrappers/tc_pairs_wrapper.py b/metplus/wrappers/tc_pairs_wrapper.py index 2764202f59..f86e814e13 100755 --- a/metplus/wrappers/tc_pairs_wrapper.py +++ b/metplus/wrappers/tc_pairs_wrapper.py @@ -24,6 +24,7 @@ from ..util import met_util as util from ..util import do_string_sub from ..util import get_tags +from ..util.met_config import add_met_config_dict_list from . import CommandBuilder '''!@namespace TCPairsWrapper @@ -315,66 +316,21 @@ def _read_storm_info(self, c_dict): c_dict['BASIN_LIST'] = basin_list def handle_consensus(self): - children = [ - 'NAME', - 'MEMBERS', - 'REQUIRED', - 'MIN_REQ' - ] - regex = r'^TC_PAIRS_CONSENSUS(\d+)_(\w+)$' - indices = util.find_indices_in_config_section(regex, self.config, - index_index=1, - id_index=2) - - consensus_dict = {} - for index, items in indices.items(): - # read all variables for each index - consensus_items = {} - - # check if any variable found doesn't match valid variables - if any([item for item in items if item not in children]): - self.log_error("Invalid variable: " - f"TC_PAIRS_CONSENSUS{index}_{item}") - - self.add_met_config( - name='name', - data_type='string', - metplus_configs=[f'TC_PAIRS_CONSENSUS{index}_NAME'], - output_dict=consensus_items - ) - self.add_met_config( - name='members', - data_type='list', - metplus_configs=[f'TC_PAIRS_CONSENSUS{index}_MEMBERS'], - output_dict=consensus_items - ) - self.add_met_config( - name='required', - data_type='list', - metplus_configs=[f'TC_PAIRS_CONSENSUS{index}_REQUIRED'], - extra_args={'remove_quotes': True}, - output_dict=consensus_items - ) - self.add_met_config( - name='min_req', - data_type='int', - metplus_configs=[f'TC_PAIRS_CONSENSUS{index}_MIN_REQ'], - output_dict=consensus_items - ) - - self.logger.debug(f'Consensus Items: {consensus_items}') - # format dictionary, then add it to consensus_dict - dict_string = self.format_met_config('dict', - consensus_items, - name='') - consensus_dict[index] = dict_string - - # format list of dictionaries - output_string = self.format_met_config('list', - consensus_dict, - 'consensus') - - self.env_var_dict['METPLUS_CONSENSUS_LIST'] = output_string + dict_items = { + 'name': 'string', + 'members': 'list', + 'required': ('list', 'remove_quotes'), + 'min_req': 'int', + } + return_code = add_met_config_dict_list(config=self.config, + app_name=self.app_name, + output_dict=self.env_var_dict, + dict_name='consensus', + dict_items=dict_items) + if not return_code: + self.isOK = False + + return return_code def run_all_times(self): """! Build up the command to invoke the MET tool tc_pairs. diff --git a/metplus/wrappers/tc_stat_wrapper.py b/metplus/wrappers/tc_stat_wrapper.py index c8a6c52a01..bb3c33e62c 100755 --- a/metplus/wrappers/tc_stat_wrapper.py +++ b/metplus/wrappers/tc_stat_wrapper.py @@ -129,15 +129,77 @@ def create_c_dict(self): # get the MET config file path or use default c_dict['CONFIG_FILE'] = self.get_config_file('TCStatConfig_wrapped') + self.set_met_config_for_environment_variables() + + return c_dict + + def set_met_config_for_environment_variables(self): + """! Set c_dict dictionary entries that will be set as environment + variables to be read by the MET config file. + @param c_dict dictionary to add key/value pairs + """ self.handle_description() - self.set_met_config_for_environment_variables() + for config_list in ['amodel', + 'bmodel', + 'storm_id', + 'basin', + 'cyclone', + 'storm_name', + 'init_hour', + 'lead_req', + 'init_mask', + 'valid_mask', + 'valid_hour', + 'lead', + 'track_watch_warn', + 'column_thresh_name', + 'column_thresh_val', + 'column_str_name', + 'column_str_val', + 'init_thresh_name', + 'init_thresh_val', + 'init_str_name', + 'init_str_val', + ]: + self.add_met_config(name=config_list, + data_type='list') + + for iv_list in ['INIT', 'VALID']: + self.add_met_config(name=f'{iv_list.lower()}_inc', + data_type='list', + metplus_configs=[f'TC_STAT_{iv_list}_INC', + f'TC_STAT_{iv_list}_INCLUDE']) + self.add_met_config(name=f'{iv_list.lower()}_exc', + data_type='list', + metplus_configs=[f'TC_STAT_{iv_list}_EXC', + f'TC_STAT_{iv_list}_EXCLUDE']) + + for config_str in ['INIT_BEG', + 'INIT_END', + 'VALID_BEG', + 'VALID_END', + 'LANDFALL_BEG', + 'LANDFALL_END', + ]: + self.add_met_config(name=config_str.lower(), + data_type='string', + metplus_configs=[f'TC_STAT_{config_str}', + config_str]) + + for config_bool in ['water_only', + 'landfall', + 'match_points', + ]: + + self.add_met_config(name=config_bool, + data_type='bool') self.add_met_config(name='column_str_exc_name', data_type='list', metplus_configs=['TC_STAT_COLUMN_STR_EXC_NAME', 'TC_STAT_COLUMN_STR_EXCLUDE_NAME', - ]) + ]) self.add_met_config(name='column_str_exc_val', data_type='list', metplus_configs=['TC_STAT_COLUMN_STR_EXC_VAL', @@ -154,77 +216,6 @@ def create_c_dict(self): 'TC_STAT_INIT_STR_EXCLUDE_VAL', ]) - return c_dict - - def set_met_config_for_environment_variables(self): - """! Set c_dict dictionary entries that will be set as environment - variables to be read by the MET config file. - @param c_dict dictionary to add key/value pairs - """ - app_name_upper = self.app_name.upper() - - for config_list in ['AMODEL', - 'BMODEL', - 'STORM_ID', - 'BASIN', - 'CYCLONE', - 'STORM_NAME', - 'INIT_HOUR', - 'LEAD_REQ', - 'INIT_MASK', - 'VALID_MASK', - 'VALID_HOUR', - 'LEAD', - 'TRACK_WATCH_WARN', - 'COLUMN_THRESH_NAME', - 'COLUMN_THRESH_VAL', - 'COLUMN_STR_NAME', - 'COLUMN_STR_VAL', - 'INIT_THRESH_NAME', - 'INIT_THRESH_VAL', - 'INIT_STR_NAME', - 'INIT_STR_VAL', - ]: - self.set_met_config_list(self.env_var_dict, - f'{app_name_upper}_{config_list}', - config_list.lower(), - f'METPLUS_{config_list}') - - for iv_list in ['INIT', 'VALID',]: - self.set_met_config_list(self.env_var_dict, - f'{app_name_upper}_{iv_list}_INCLUDE', - f'{iv_list.lower()}_inc', - f'METPLUS_{iv_list}_INC' - ) - self.set_met_config_list(self.env_var_dict, - f'{app_name_upper}_{iv_list}_EXCLUDE', - f'{iv_list.lower()}_exc', - f'METPLUS_{iv_list}_EXC' - ) - - for config_str in ['INIT_BEG', - 'INIT_END', - 'VALID_BEG', - 'VALID_END', - 'LANDFALL_BEG', - 'LANDFALL_END', - ]: - self.set_met_config_string(self.env_var_dict, - [f'{app_name_upper}_{config_str}', - f'{config_str}'], - config_str.lower(), - f'METPLUS_{config_str}') - - for config_bool in ['WATER_ONLY', - 'LANDFALL', - 'MATCH_POINTS', - ]: - - self.set_met_config_bool(self.env_var_dict, - f'{app_name_upper}_{config_bool}', - config_bool.lower(), - f'METPLUS_{config_bool}') - def run_at_time(self, input_dict=None): """! Builds the call to the MET tool TC-STAT for all requested initialization times (init or valid). Called from run_metplus diff --git a/metplus/wrappers/tcrmw_wrapper.py b/metplus/wrappers/tcrmw_wrapper.py index 5cf562017f..a5ce97553d 100755 --- a/metplus/wrappers/tcrmw_wrapper.py +++ b/metplus/wrappers/tcrmw_wrapper.py @@ -16,6 +16,7 @@ from ..util import time_util from . import CommandBuilder from ..util import do_string_sub +from ..util import parse_var_list '''!@namespace TCRMWWrapper @brief Wraps the TC-RMW tool @@ -81,92 +82,85 @@ def create_c_dict(self): 'TC_RMW_DECK_TEMPLATE') ) - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_INPUT_DATATYPE', - 'file_type', - 'METPLUS_DATA_FILE_TYPE') + self.add_met_config(name='file_type', + data_type='string', + env_var_name='METPLUS_DATA_FILE_TYPE', + metplus_configs=['TC_RMW_INPUT_DATATYPE', + 'TC_RMW_FILE_TYPE']) - # values used in configuration file - self.set_met_config_string(self.env_var_dict, - 'MODEL', - 'model', - 'METPLUS_MODEL') + self.add_met_config(name='model', + data_type='string', + metplus_configs=['MODEL']) self.handle_regrid(c_dict, set_to_grid=False) - self.set_met_config_int(self.env_var_dict, - 'TC_RMW_N_RANGE', - 'n_range', - 'METPLUS_N_RANGE') - - self.set_met_config_int(self.env_var_dict, - 'TC_RMW_N_AZIMUTH', - 'n_azimuth', - 'METPLUS_N_AZIMUTH') - - self.set_met_config_float(self.env_var_dict, - 'TC_RMW_MAX_RANGE_KM', - 'max_range_km', - 'METPLUS_MAX_RANGE_KM') - - self.set_met_config_float(self.env_var_dict, - 'TC_RMW_DELTA_RANGE_KM', - 'delta_range_km', - 'METPLUS_DELTA_RANGE_KM') - - self.set_met_config_float(self.env_var_dict, - 'TC_RMW_SCALE', - 'rmw_scale', - 'METPLUS_RMW_SCALE') - - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_STORM_ID', - 'storm_id', - 'METPLUS_STORM_ID') - - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_BASIN', - 'basin', - 'METPLUS_BASIN') - - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_CYCLONE', - 'cyclone', - 'METPLUS_CYCLONE') - - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_INIT_INCLUDE', - 'init_inc', - 'METPLUS_INIT_INCLUDE') - - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_VALID_BEG', - 'valid_beg', - 'METPLUS_VALID_BEG') - - self.set_met_config_string(self.env_var_dict, - 'TC_RMW_VALID_END', - 'valid_end', - 'METPLUS_VALID_END') - - self.set_met_config_list(self.env_var_dict, - 'TC_RMW_VALID_INCLUDE_LIST', - 'valid_inc', - 'METPLUS_VALID_INCLUDE_LIST') - - self.set_met_config_list(self.env_var_dict, - 'TC_RMW_VALID_EXCLUDE_LIST', - 'valid_exc', - 'METPLUS_VALID_EXCLUDE_LIST') - - self.set_met_config_list(self.env_var_dict, - 'TC_RMW_VALID_HOUR_LIST', - 'valid_hour', - 'METPLUS_VALID_HOUR_LIST') - - c_dict['VAR_LIST_TEMP'] = util.parse_var_list(self.config, - data_type='FCST', - met_tool=self.app_name) + self.add_met_config(name='n_range', + data_type='int') + + self.add_met_config(name='n_azimuth', + data_type='int') + + self.add_met_config(name='max_range_km', + data_type='float') + + self.add_met_config(name='delta_range_km', + data_type='float') + + self.add_met_config(name='rmw_scale', + data_type='float') + + self.add_met_config(name='storm_id', + data_type='string') + + self.add_met_config(name='basin', + data_type='string') + + self.add_met_config(name='cyclone', + data_type='string') + + self.add_met_config(name='init_inc', + data_type='string', + env_var_name='METPLUS_INIT_INCLUDE', + metplus_configs=['TC_RMW_INIT_INC', + 'TC_RMW_INIT_INCLUDE']) + + self.add_met_config(name='valid_beg', + data_type='string', + metplus_configs=['TC_RMW_VALID_BEG', + 'TC_RMW_VALID_BEGIN']) + + self.add_met_config(name='valid_end', + data_type='string', + metplus_configs=['TC_RMW_VALID_END']) + + self.add_met_config(name='valid_inc', + data_type='list', + env_var_name='METPLUS_VALID_INCLUDE_LIST', + metplus_configs=['TC_RMW_VALID_INCLUDE_LIST', + 'TC_RMW_VALID_INC_LIST', + 'TC_RMW_VALID_INCLUDE', + 'TC_RMW_VALID_INC', + ]) + + self.add_met_config(name='valid_exc', + data_type='list', + env_var_name='METPLUS_VALID_EXCLUDE_LIST', + metplus_configs=['TC_RMW_VALID_EXCLUDE_LIST', + 'TC_RMW_VALID_EXC_LIST', + 'TC_RMW_VALID_EXCLUDE', + 'TC_RMW_VALID_EXC', + ]) + + self.add_met_config(name='valid_hour', + data_type='list', + env_var_name='METPLUS_VALID_HOUR_LIST', + metplus_configs=['TC_RMW_VALID_HOUR_LIST', + 'TC_RMW_VALID_HOUR', + ]) + + c_dict['VAR_LIST_TEMP'] = parse_var_list(self.config, + data_type='FCST', + met_tool=self.app_name) return c_dict From cfc1d0968f33fa37e46be9ac1c56f7a30315b00a Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 6 Dec 2021 15:20:53 -0700 Subject: [PATCH 19/43] removed deprecated sections from config examples --- docs/Users_Guide/systemconfiguration.rst | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/docs/Users_Guide/systemconfiguration.rst b/docs/Users_Guide/systemconfiguration.rst index e1aeaa9389..5e96db659b 100644 --- a/docs/Users_Guide/systemconfiguration.rst +++ b/docs/Users_Guide/systemconfiguration.rst @@ -1059,7 +1059,6 @@ no space between the process name and the parenthesis. [config] PROCESS_LIST = GridStat, GridStat(my_instance_name) - [dir] GRID_STAT_OUTPUT_DIR = /grid/stat/output/dir [my_instance_name] @@ -1164,10 +1163,8 @@ CUSTOM_LOOP_LIST for that wrapper only. PCP_COMBINE_CUSTOM_LOOP_LIST = mem_001, mem_002 - [dir] FCST_PCP_COMBINE_INPUT_DIR = /d1/ensemble - [filename_templates] FCST_PCP_COMBINE_INPUT_TEMPLATE = {custom?fmt=%s}/{valid?fmt=%Y%m%d}.nc This configuration will run the following: @@ -1193,7 +1190,6 @@ This configuration will run the following: SERIES_ANALYSIS_CONFIG_FILE = {CONFIG_DIR}/SAConfig_{custom?fmt=%s} - [dir] SERIES_ANALYSIS_OUTPUT_DIR = {OUTPUT_BASE}/SA/{custom?fmt=%s} This configuration will run SeriesAnalysis: @@ -1470,10 +1466,9 @@ Using Templates to find Observation Data The following configuration variables describe input observation data:: - [dir] + [config] OBS_GRID_STAT_INPUT_DIR = /my/path/to/grid_stat/input/obs - [filename_templates] OBS_GRID_STAT_INPUT_TEMPLATE = {valid?fmt=%Y%m%d}/prefix.{valid?fmt=%Y%m%d%H}.ext The input directory is the top level directory containing all of the @@ -1511,10 +1506,9 @@ Most forecast files contain the initialization time and the forecast lead in the filename. The keywords 'init' and 'lead' can be used to describe the template of these files:: - [dir] + [config] FCST_GRID_STAT_INPUT_DIR = /my/path/to/grid_stat/input/fcst - [filename_templates] FCST_GRID_STAT_INPUT_TEMPLATE = prefix.{init?fmt=%Y%m%d%H}_f{lead?fmt=%3H}.ext For a valid time of 20190201_00Z and a forecast lead of 3, METplus Wrappers @@ -1533,10 +1527,8 @@ the valid time of the data. Consider the following configuration:: [config] PB2NC_OFFSETS = 6, 3 - [dir] PB2NC_INPUT_DIR = /my/path/to/prepbufr - [filename_templates] PB2NC_INPUT_TEMPLATE = prefix.{da_init?fmt=%Y%m%d}_{cycle?fmt=%H}_off{offset?fmt=%2H}.ext The PB2NC_OFFSETS list tells METplus Wrappers the order in which to @@ -1565,7 +1557,7 @@ the following day. In this example, for a run at 00Z you want to use the file from the previous day and for the 01Z to 23Z runs you want to use the file that corresponds to the current day. Here is an example:: - [filename_templates] + [config] OBS_POINT_STAT_INPUT_TEMPLATE = {valid?fmt=%Y%m%d?shift=-3600}.ext Running the above configuration at a valid time of 20190201_12Z will shift @@ -1593,10 +1585,8 @@ configuration:: OBS_FILE_WINDOW_BEGIN = -7200 OBS_FILE_WINDOW_END = 7200 - [dir] OBS_GRID_STAT_INPUT_DIR = /my/grid_stat/input/obs - [filename_templates] OBS_GRID_STAT_INPUT_TEMPLATE = {valid?fmt=%Y%m%d}/pre.{valid?fmt=%Y%m%d}_{valid?fmt=%H}.ext For a run time of 20190201_00Z, and a set of files in the input directory @@ -1710,10 +1700,8 @@ how these variables affect how the data is processed. PROCESS_LIST = SeriesAnalysis - [dir] FCST_SERIES_ANALYSIS_INPUT_DIR = /my/fcst/dir - [filename_templates] FCST_SERIES_ANALYSIS_INPUT_TEMPLATE = I{init?fmt=%Y%m%d%H}_F{lead?fmt=%3H}_V{valid?fmt=%H} In this example, the wrapper will go through all initialization and forecast From dd0d474b82e8cbfabba5945e30c92adc854c53c9 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 7 Dec 2021 12:58:15 -0700 Subject: [PATCH 20/43] minor change to METplus release guide to add a link to the PDF of the User's Guide instead of downloading it and attaching it to the release --- docs/Release_Guide/release_steps/create_release_on_github.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Release_Guide/release_steps/create_release_on_github.rst b/docs/Release_Guide/release_steps/create_release_on_github.rst index b1eea4fa3f..a20cd4f28a 100644 --- a/docs/Release_Guide/release_steps/create_release_on_github.rst +++ b/docs/Release_Guide/release_steps/create_release_on_github.rst @@ -13,7 +13,7 @@ Create Release on GitHub https://|projectRepo|.readthedocs.io/en/vX.Y.Z-betaN/Users_Guide/release-notes.html (Note: the URL will not be active until the release is created) -* Attach a PDF of the |projectRepo| User's Guide, if available. +* Add a link to the PDF of the |projectRepo| User's Guide, if available. The PDF can be downloaded from ReadTheDocs if it is available, i.e. https://|projectRepo|.readthedocs.io/_/downloads/en/vX.Y.Z-betaN/pdf/ (Note: the URL will not be active until the release is created) From 1af654cedf59dbad72487885009bd4faa8d964c2 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 16 Dec 2021 13:54:59 -0700 Subject: [PATCH 21/43] Feature 1285 extract tiles mtd times (#1315) --- .github/jobs/get_use_case_commands.py | 8 +- docs/Users_Guide/glossary.rst | 20 + docs/Users_Guide/systemconfiguration.rst | 61 ++- .../pytests/met_util/test_met_util.py | 134 ------- .../pytests/time_util/test_time_util.py | 2 +- .../string_manip/test_util_string_manip.py | 112 ++++++ .../util/time_looping/test_time_looping.py | 124 ++++++ metplus/util/__init__.py | 2 + metplus/util/config_metplus.py | 3 +- metplus/util/met_config.py | 3 +- metplus/util/met_util.py | 359 ++---------------- metplus/util/string_manip.py | 189 +++++++++ metplus/util/time_looping.py | 164 ++++++++ metplus/wrappers/command_builder.py | 50 +-- metplus/wrappers/cyclone_plotter_wrapper.py | 24 +- metplus/wrappers/extract_tiles_wrapper.py | 39 +- metplus/wrappers/gen_vx_mask_wrapper.py | 11 +- metplus/wrappers/make_plots_wrapper.py | 37 +- metplus/wrappers/pb2nc_wrapper.py | 7 +- metplus/wrappers/point_stat_wrapper.py | 3 +- metplus/wrappers/runtime_freq_wrapper.py | 140 +++---- metplus/wrappers/series_analysis_wrapper.py | 21 +- metplus/wrappers/stat_analysis_wrapper.py | 9 +- metplus/wrappers/tc_gen_wrapper.py | 8 +- metplus/wrappers/tc_pairs_wrapper.py | 27 +- metplus/wrappers/tcmpr_plotter_wrapper.py | 16 +- ...pt_obsPrecip_obsOnly_CrossSpectraPlot.conf | 41 +- ...erScript_obsPrecip_obsOnly_Hovmoeller.conf | 41 +- 28 files changed, 877 insertions(+), 778 deletions(-) create mode 100644 internal_tests/pytests/util/string_manip/test_util_string_manip.py create mode 100644 internal_tests/pytests/util/time_looping/test_time_looping.py create mode 100644 metplus/util/string_manip.py create mode 100644 metplus/util/time_looping.py diff --git a/.github/jobs/get_use_case_commands.py b/.github/jobs/get_use_case_commands.py index 70381d135d..63e86ce21e 100755 --- a/.github/jobs/get_use_case_commands.py +++ b/.github/jobs/get_use_case_commands.py @@ -129,7 +129,8 @@ def main(categories, subset_list, work_dir=None, setup_env, py_embed_arg = handle_automation_env(host_name, reqs, work_dir) - use_case_cmds = [] + # use status variable to track if any use cases failed + use_case_cmds = ['status=0'] for use_case in use_case_by_requirement.use_cases: # add parm/use_cases path to config args if they are conf files config_args = [] @@ -147,7 +148,12 @@ def main(categories, subset_list, work_dir=None, f" {py_embed_arg}{test_settings_conf}" f" config.OUTPUT_BASE={output_base}") use_case_cmds.append(use_case_cmd) + # check exit code from use case command and + # set status to non-zero value on error + use_case_cmds.append("if [ $? != 0 ]; then status=1; fi") + # if any use cases failed, force non-zero exit code with false + use_case_cmds.append("if [ $status != 0 ]; then false; fi") # add commands to set up environment before use case commands group_commands = f"{setup_env}{';'.join(use_case_cmds)}" all_commands.append((group_commands, reqs)) diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index 3b5cee3f5f..ca701205ce 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -8529,3 +8529,23 @@ METplus Configuration Glossary Specify the value for 'obs_quality_exc' in the MET configuration file for EnsembleStat. | *Used by:* EnsembleStat + + INIT_LIST + List of initialization times to process. + This variable is used when intervals between run times are irregular. + It is only read if :term:`LOOP_BY` = INIT. If it is set, then + :term:`INIT_BEG`, :term:`INIT_END`, and :term:`INIT_INCREMENT` + are ignored. All values in the list must match the format of + :term:`INIT_TIME_FMT` or they will be skipped. + + | *Used by:* All + + VALID_LIST + List of valid times to process. + This variable is used when intervals between run times are irregular. + It is only read if :term:`LOOP_BY` = VALID. If it is set, then + :term:`VALID_BEG`, :term:`VALID_END`, and :term:`VALID_INCREMENT` + are ignored. All values in the list must match the format of + :term:`VALID_TIME_FMT` or they will be skipped. + + | *Used by:* All diff --git a/docs/Users_Guide/systemconfiguration.rst b/docs/Users_Guide/systemconfiguration.rst index 5e96db659b..1bcb65ad82 100644 --- a/docs/Users_Guide/systemconfiguration.rst +++ b/docs/Users_Guide/systemconfiguration.rst @@ -632,7 +632,9 @@ Looping by Valid Time When looping over valid time (`LOOP_BY` = VALID or REALTIME), the following variables must be set: -:term:`VALID_TIME_FMT`: +:term:`VALID_TIME_FMT` +"""""""""""""""""""""" + This is the format of the valid times the user can configure in the METplus Wrappers. The value of `VALID_BEG` and `VALID_END` must correspond to this format. @@ -644,13 +646,17 @@ Example:: Using this format, the valid time range values specified must be defined as YYYYMMDDHH, i.e. 2019020112. -:term:`VALID_BEG`: +:term:`VALID_BEG` +""""""""""""""""" + This is the first valid time that will be processed. The format of this variable is controlled by :term:`VALID_TIME_FMT`. For example, if VALID_TIME_FMT=%Y%m%d, then VALID_BEG must be set to a valid time matching YYYYMMDD, such as 20190201. -:term:`VALID_END`: +:term:`VALID_END` +""""""""""""""""" + This is the last valid time that can be processed. The format of this variable is controlled by :term:`VALID_TIME_FMT`. For example, if VALID_TIME_FMT=%Y%m%d, then VALID_END must be set to a valid time matching @@ -667,7 +673,9 @@ YYYYMMDD, such as 20190202. Wrappers will process valid times 20190201 and 20190202 before ending execution. -:term:`VALID_INCREMENT`: +:term:`VALID_INCREMENT` +""""""""""""""""""""""" + This is the time interval to add to each run time to determine the next run time to process. See :ref:`time-interval-units` for information on time interval formatting. Units of hours are assumed if no units are specified. @@ -689,6 +697,20 @@ The following is a configuration that will process valid time 2019-02-01 at This will process data valid on 2019-02-01 at 00Z, 06Z, 12Z, and 18Z as well as 2019-02-02 at 00Z. For each of these valid times, the METplus wrappers can also loop over a set of forecast leads that are all valid at the current run time. See :ref:`looping_over_forecast_leads` for more information. +:term:`VALID_LIST` +"""""""""""""""""" + +If the intervals between run times are irregular, then an explicit list of +times can be defined. The following example will process the same times +as the previous example:: + + [config] + LOOP_BY = VALID + VALID_TIME_FMT = %Y%m%d%H + VALID_LIST = 2019020100, 2019020106, 2019020112, 2019020118, 2019020200 + +See the glossary entry for :term:`VALID_LIST` for more information. + .. _Looping_by_Initialization_Time: Looping by Initialization Time @@ -696,19 +718,27 @@ Looping by Initialization Time When looping over initialization time (:term:`LOOP_BY` = INIT or LOOP_BY = RETRO), the following variables must be set: -:term:`INIT_TIME_FMT`: +:term:`INIT_TIME_FMT` +""""""""""""""""""""" + This is the format of the initialization times the user can configure in METplus Wrappers. The value of :term:`INIT_BEG` and :term:`INIT_END` must correspond to this format. Example: INIT_TIME_FMT = %Y%m%d%H. Using this format, the initialization time range values specified must be defined as YYYYMMDDHH, i.e. 2019020112. -:term:`INIT_BEG`: +:term:`INIT_BEG` +"""""""""""""""" + This is the first initialization time that will be processed. The format of this variable is controlled by :term:`INIT_TIME_FMT`. For example, if INIT_TIME_FMT = %Y%m%d, then INIT_BEG must be set to an initialization time matching YYYYMMDD, such as 20190201. -:term:`INIT_END`: +:term:`INIT_END` +"""""""""""""""" + This is the last initialization time that can be processed. The format of this variable is controlled by INIT_TIME_FMT. For example, if INIT_TIME_FMT = %Y%m%d, then INIT_END must be set to an initialization time matching YYYYMMDD, such as 20190202. .. note:: The time specified for this variable will not necessarily be processed. It is used to determine the cutoff of run times that can be processed. For example, if METplus Wrappers is configured to start at 2019-02-01 and end at 2019-02-02 processing data in 48 hour increments, it will process 2019-02-01 then increment the run time to 2019-02-03. This is later than the INIT_END valid, so execution will stop. However, if the increment is set to 24 hours (see INIT_INCREMENT), then METplus Wrappers will process initialization times 2019-02-01 and 2019-02-02 before ending executaion. -:term:`INIT_INCREMENT`: +:term:`INIT_INCREMENT` +"""""""""""""""""""""" + This is the time interval to add to each run time to determine the next run time to process. See :ref:`time-interval-units` for information on time interval formatting. Units of hours are assumed if no units are specified. This value must be greater than or equal to 60 seconds because the METplus wrappers currently do not support processing intervals of less than one minute. The following is a configuration that will process initialization time 2019-02-01 at 00Z until 2019-02-02 at 00Z in 6 hour (21600 second) increments:: @@ -725,6 +755,21 @@ The following is a configuration that will process initialization time 2019-02-0 This will process data initialized on 2019-02-01 at 00Z, 06Z, 12Z, and 18Z as well as 2019-02-02 at 00Z. For each of these initialization times, METplus Wrappers can also loop over a set of forecast leads that are all initialized at the current run time. See :ref:`looping_over_forecast_leads` for more information. +:term:`INIT_LIST` +""""""""""""""""" + +If the intervals between run times are irregular, then an explicit list of +times can be defined. The following example will process the same times +as the previous example:: + + [config] + LOOP_BY = INIT + INIT_TIME_FMT = %Y%m%d%H + INIT_LIST = 2019020100, 2019020106, 2019020112, 2019020118, 2019020200 + +See the glossary entry for :term:`INIT_LIST` for more information. + + .. _looping_over_forecast_leads: Looping over Forecast Leads diff --git a/internal_tests/pytests/met_util/test_met_util.py b/internal_tests/pytests/met_util/test_met_util.py index cb3f04af7d..7ff9bc2b74 100644 --- a/internal_tests/pytests/met_util/test_met_util.py +++ b/internal_tests/pytests/met_util/test_met_util.py @@ -4,7 +4,6 @@ import datetime import os from dateutil.relativedelta import relativedelta -from csv import reader import pprint import pytest @@ -153,31 +152,6 @@ def test_preprocess_file_options(metplus_config, result = util.preprocess_file(filename, data_type, config, allow_dir) assert(result == expected) -def test_getlist(): - l = 'gt2.7, >3.6, eq42' - test_list = util.getlist(l) - assert(test_list == ['gt2.7', '>3.6', 'eq42']) - -def test_getlist_int(): - l = '6, 7, 42' - test_list = util.getlistint(l) - assert(test_list == [6, 7, 42]) - -def test_getlist_float(): - l = '6.2, 7.8, 42.0' - test_list = util.getlistfloat(l) - assert(test_list == [6.2, 7.8, 42.0]) - -def test_getlist_has_commas(): - l = 'gt2.7, >3.6, eq42, "has,commas,in,it"' - test_list = util.getlist(l) - assert(test_list == ['gt2.7', '>3.6', 'eq42', 'has,commas,in,it']) - -def test_getlist_empty(): - l = '' - test_list = util.getlist(l) - assert(test_list == []) - def test_get_lead_sequence_lead(metplus_config): input_dict = {'valid': datetime.datetime(2019, 2, 1, 13)} conf = metplus_config() @@ -263,64 +237,6 @@ def test_get_lead_sequence_groups(metplus_config, config_dict, expected_list): assert(hour_seq == expected_list) -@pytest.mark.parametrize( - 'list_string, output_list', [ - ('begin_end_incr(3,12,3)', - ['3', '6', '9', '12']), - - ('1,2,3,4', - ['1', '2', '3', '4']), - - (' 1,2,3,4', - ['1', '2', '3', '4']), - - ('1,2,3,4 ', - ['1', '2', '3', '4']), - - (' 1,2,3,4 ', - ['1', '2', '3', '4']), - - ('1, 2,3,4', - ['1', '2', '3', '4']), - - ('1,2, 3, 4', - ['1', '2', '3', '4']), - - ('begin_end_incr( 3,12 , 3)', - ['3', '6', '9', '12']), - - ('begin_end_incr(0,10,2)', - ['0', '2', '4', '6', '8', '10']), - - ('begin_end_incr(10,0,-2)', - ['10', '8', '6', '4', '2', '0']), - - ('begin_end_incr(2,2,20)', - ['2']), - - ('begin_end_incr(0,2,1), begin_end_incr(3,9,3)', - ['0','1','2','3','6','9']), - - ('mem_begin_end_incr(0,2,1), mem_begin_end_incr(3,9,3)', - ['mem_0','mem_1','mem_2','mem_3','mem_6','mem_9']), - - ('mem_begin_end_incr(0,2,1,3), mem_begin_end_incr(3,12,3,3)', - ['mem_000', 'mem_001', 'mem_002', 'mem_003', 'mem_006', 'mem_009', 'mem_012']), - - ('begin_end_incr(0,10,2)H, 12', [ '0H', '2H', '4H', '6H', '8H', '10H', '12']), - - ('begin_end_incr(0,10800,3600)S, 4H', [ '0S', '3600S', '7200S', '10800S', '4H']), - - ('data.{init?fmt=%Y%m%d%H?shift=begin_end_incr(0, 3, 3)H}.ext', - ['data.{init?fmt=%Y%m%d%H?shift=0H}.ext', - 'data.{init?fmt=%Y%m%d%H?shift=3H}.ext', - ]), - - ] -) -def test_getlist_begin_end_incr(list_string, output_list): - assert(util.getlist(list_string) == output_list) - @pytest.mark.parametrize( 'current_hour, lead_seq', [ (0, [0, 12, 24, 36]), @@ -367,56 +283,6 @@ def test_get_lead_sequence_init_min_10(metplus_config): lead_seq = [12, 24] assert(test_seq == [relativedelta(hours=lead) for lead in lead_seq]) -@pytest.mark.parametrize( - 'time_from_conf, fmt, is_datetime', [ - ('', '%Y', False), - ('a', '%Y', False), - ('1987', '%Y', True), - ('1987', '%Y%m', False), - ('198702', '%Y%m', True), - ('198702', '%Y%m%d', False), - ('19870201', '%Y%m%d', True), - ('19870201', '%Y%m%d%H', False), - ('{now?fmt=%Y%m%d}', '%Y%m%d', True), - ('{now?fmt=%Y%m%d}', '%Y%m%d%H', True), - ('{now?fmt=%Y%m%d}00', '%Y%m%d%H', True), - ('{today}', '%Y%m%d', True), - ('{today}', '%Y%m%d%H', True), - ] -) -def test_get_time_obj(time_from_conf, fmt, is_datetime): - clock_time = datetime.datetime(2019, 12, 31, 15, 30) - - time_obj = util.get_time_obj(time_from_conf, fmt, clock_time) - - assert(isinstance(time_obj, datetime.datetime) == is_datetime) - -@pytest.mark.parametrize( - 'list_str, expected_fixed_list', [ - ('some,items,here', ['some', - 'items', - 'here']), - ('(*,*)', ['(*,*)']), - ("-type solar_alt -thresh 'ge45' -name solar_altitude_ge_45_mask -input_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;' -mask_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;\'", - ["-type solar_alt -thresh 'ge45' -name solar_altitude_ge_45_mask -input_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;' -mask_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;\'"]), - ("(*,*),'level=\"(0,*,*)\"' -censor_thresh [lt12.3,gt8.8],other", ['(*,*)', - "'level=\"(0,*,*)\"' -censor_thresh [lt12.3,gt8.8]", - 'other']), - ] -) -def test_fix_list(list_str, expected_fixed_list): - item_list = list(reader([list_str]))[0] - fixed_list = util.fix_list(item_list) - print("FIXED LIST:") - for fixed in fixed_list: - print(f"ITEM: {fixed}") - - print("EXPECTED LIST") - for expected in expected_fixed_list: - print(f"ITEM: {expected}") - - assert(fixed_list == expected_fixed_list) - @pytest.mark.parametrize( 'camel, underscore', [ ('ASCII2NCWrapper', 'ascii2nc_wrapper'), diff --git a/internal_tests/pytests/time_util/test_time_util.py b/internal_tests/pytests/time_util/test_time_util.py index 0954df4a71..bec515faff 100644 --- a/internal_tests/pytests/time_util/test_time_util.py +++ b/internal_tests/pytests/time_util/test_time_util.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import sys import pytest diff --git a/internal_tests/pytests/util/string_manip/test_util_string_manip.py b/internal_tests/pytests/util/string_manip/test_util_string_manip.py new file mode 100644 index 0000000000..9ceab10ed3 --- /dev/null +++ b/internal_tests/pytests/util/string_manip/test_util_string_manip.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 + +import pytest + +from csv import reader + +from metplus.util.string_manip import * +from metplus.util.string_manip import _fix_list + +def test_getlist(): + string_list = 'gt2.7, >3.6, eq42' + test_list = getlist(string_list) + assert test_list == ['gt2.7', '>3.6', 'eq42'] + +def test_getlist_int(): + string_list = '6, 7, 42' + test_list = getlistint(string_list) + assert test_list == [6, 7, 42] + +def test_getlist_has_commas(): + string_list = 'gt2.7, >3.6, eq42, "has,commas,in,it"' + test_list = getlist(string_list) + assert test_list == ['gt2.7', '>3.6', 'eq42', 'has,commas,in,it'] + +def test_getlist_empty(): + string_list = '' + test_list = getlist(string_list) + assert test_list == [] + +@pytest.mark.parametrize( + 'list_string, output_list', [ + ('begin_end_incr(3,12,3)', + ['3', '6', '9', '12']), + + ('1,2,3,4', + ['1', '2', '3', '4']), + + (' 1,2,3,4', + ['1', '2', '3', '4']), + + ('1,2,3,4 ', + ['1', '2', '3', '4']), + + (' 1,2,3,4 ', + ['1', '2', '3', '4']), + + ('1, 2,3,4', + ['1', '2', '3', '4']), + + ('1,2, 3, 4', + ['1', '2', '3', '4']), + + ('begin_end_incr( 3,12 , 3)', + ['3', '6', '9', '12']), + + ('begin_end_incr(0,10,2)', + ['0', '2', '4', '6', '8', '10']), + + ('begin_end_incr(10,0,-2)', + ['10', '8', '6', '4', '2', '0']), + + ('begin_end_incr(2,2,20)', + ['2']), + + ('begin_end_incr(0,2,1), begin_end_incr(3,9,3)', + ['0','1','2','3','6','9']), + + ('mem_begin_end_incr(0,2,1), mem_begin_end_incr(3,9,3)', + ['mem_0','mem_1','mem_2','mem_3','mem_6','mem_9']), + + ('mem_begin_end_incr(0,2,1,3), mem_begin_end_incr(3,12,3,3)', + ['mem_000', 'mem_001', 'mem_002', 'mem_003', 'mem_006', 'mem_009', 'mem_012']), + + ('begin_end_incr(0,10,2)H, 12', [ '0H', '2H', '4H', '6H', '8H', '10H', '12']), + + ('begin_end_incr(0,10800,3600)S, 4H', [ '0S', '3600S', '7200S', '10800S', '4H']), + + ('data.{init?fmt=%Y%m%d%H?shift=begin_end_incr(0, 3, 3)H}.ext', + ['data.{init?fmt=%Y%m%d%H?shift=0H}.ext', + 'data.{init?fmt=%Y%m%d%H?shift=3H}.ext', + ]), + + ] +) +def test_getlist_begin_end_incr(list_string, output_list): + assert getlist(list_string) == output_list + +@pytest.mark.parametrize( + 'list_str, expected_fixed_list', [ + ('some,items,here', ['some', + 'items', + 'here']), + ('(*,*)', ['(*,*)']), + ("-type solar_alt -thresh 'ge45' -name solar_altitude_ge_45_mask -input_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;' -mask_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;\'", + ["-type solar_alt -thresh 'ge45' -name solar_altitude_ge_45_mask -input_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;' -mask_field 'name=\"TEC\"; level=\"(0,*,*)\"; file_type=NETCDF_NCCF;\'"]), + ("(*,*),'level=\"(0,*,*)\"' -censor_thresh [lt12.3,gt8.8],other", ['(*,*)', + "'level=\"(0,*,*)\"' -censor_thresh [lt12.3,gt8.8]", + 'other']), + ] +) +def test_fix_list(list_str, expected_fixed_list): + item_list = list(reader([list_str]))[0] + fixed_list = _fix_list(item_list) + print("FIXED LIST:") + for fixed in fixed_list: + print(f"ITEM: {fixed}") + + print("EXPECTED LIST") + for expected in expected_fixed_list: + print(f"ITEM: {expected}") + + assert(fixed_list == expected_fixed_list) diff --git a/internal_tests/pytests/util/time_looping/test_time_looping.py b/internal_tests/pytests/util/time_looping/test_time_looping.py new file mode 100644 index 0000000000..7326734b48 --- /dev/null +++ b/internal_tests/pytests/util/time_looping/test_time_looping.py @@ -0,0 +1,124 @@ +import pytest + +from metplus.util.time_looping import * + +def test_time_generator_list(metplus_config): + for prefix in ['INIT', 'VALID']: + config = metplus_config() + config.set('config', 'LOOP_BY', prefix) + config.set('config', f'{prefix}_TIME_FMT', '%Y%m%d%H') + config.set('config', f'{prefix}_LIST', '2021020104, 2021103121') + + expected_times = [ + datetime.strptime('2021020104', '%Y%m%d%H'), + datetime.strptime('2021103121', '%Y%m%d%H'), + ] + + generator = time_generator(config) + assert next(generator)[prefix.lower()] == expected_times[0] + assert next(generator)[prefix.lower()] == expected_times[1] + try: + next(generator) + assert False + except StopIteration: + assert True + +def test_time_generator_increment(metplus_config): + for prefix in ['INIT', 'VALID']: + config = metplus_config() + config.set('config', 'LOOP_BY', prefix) + config.set('config', f'{prefix}_TIME_FMT', '%Y%m%d%H') + config.set('config', f'{prefix}_BEG', '2021020104') + config.set('config', f'{prefix}_END', '2021020106') + config.set('config', f'{prefix}_INCREMENT', '1H') + + expected_times = [ + datetime.strptime('2021020104', '%Y%m%d%H'), + datetime.strptime('2021020105', '%Y%m%d%H'), + datetime.strptime('2021020106', '%Y%m%d%H'), + ] + + generator = time_generator(config) + assert next(generator)[prefix.lower()] == expected_times[0] + assert next(generator)[prefix.lower()] == expected_times[1] + assert next(generator)[prefix.lower()] == expected_times[2] + try: + next(generator) + assert False + except StopIteration: + assert True + +def test_time_generator_error_check(metplus_config): + """! Test that None is returned by the time generator when + the time looping config variables are not set properly. Tests: + Missing LOOP_BY, + Missing [INIT/VALID]_TIME_FMT, + Empty [INIT/VALID]_LIST (if set), + List value doesn't match time format, + _BEG or _END value doesn't match format, + _INCREMENT is less than 60 seconds, + _BEG is after _END, + """ + time_fmt = '%Y%m%d%H' + for prefix in ['INIT', 'VALID']: + config = metplus_config() + + # unset LOOP_BY + assert next(time_generator(config)) is None + config.set('config', 'LOOP_BY', prefix) + + # unset _TIME_FMT + assert next(time_generator(config)) is None + config.set('config', f'{prefix}_TIME_FMT', time_fmt) + + # test [INIT/VALID]_LIST configurations + + # empty _LIST + config.set('config', f'{prefix}_LIST', '') + assert next(time_generator(config)) is None + + # list value doesn't match format + config.set('config', f'{prefix}_LIST', '202102010412') + assert next(time_generator(config)) is None + + # 2nd list value doesn't match format + config.set('config', f'{prefix}_LIST', '2021020104, 202102010412') + expected_time = datetime.strptime('2021020104', time_fmt) + generator = time_generator(config) + assert next(generator)[prefix.lower()] == expected_time + assert next(generator) is None + + # good _LIST + config.set('config', f'{prefix}_LIST', '2021020104') + assert next(time_generator(config))[prefix.lower()] == expected_time + + # get a fresh config object to test BEG/END configurations + config = metplus_config() + config.set('config', 'LOOP_BY', prefix) + config.set('config', f'{prefix}_TIME_FMT', time_fmt) + + # _BEG doesn't match time format (too long) + config.set('config', f'{prefix}_BEG', '202110311259') + config.set('config', f'{prefix}_END', '2021112012') + + assert next(time_generator(config)) is None + config.set('config', f'{prefix}_BEG', '2021103112') + + # unset _END uses _BEG value, so it should succeed + assert next(time_generator(config)) is not None + + # _END doesn't match time format (too long) + config.set('config', f'{prefix}_END', '202111201259') + + assert next(time_generator(config)) is None + config.set('config', f'{prefix}_END', '2021112012') + assert next(time_generator(config)) is not None + + # _INCREMENT is less than 60 seconds + config.set('config', f'{prefix}_INCREMENT', '10S') + assert next(time_generator(config)) is None + config.set('config', f'{prefix}_INCREMENT', '1d') + + # _END time comes before _BEG time + config.set('config', f'{prefix}_END', '2020112012') + assert next(time_generator(config)) is None diff --git a/metplus/util/__init__.py b/metplus/util/__init__.py index b124dcb5b8..368f1caae0 100644 --- a/metplus/util/__init__.py +++ b/metplus/util/__init__.py @@ -1,4 +1,5 @@ from .constants import * +from .string_manip import * from .metplus_check import * from .doc_util import * from .config_metplus import * @@ -6,3 +7,4 @@ from .met_util import * from .string_template_substitution import * from .met_config import * +from .time_looping import * diff --git a/metplus/util/config_metplus.py b/metplus/util/config_metplus.py index 892990994e..e2cc3981cd 100644 --- a/metplus/util/config_metplus.py +++ b/metplus/util/config_metplus.py @@ -22,7 +22,8 @@ from . import met_util as util from .string_template_substitution import get_tags, do_string_sub -from .met_util import getlist, is_python_script, format_var_items +from .met_util import is_python_script, format_var_items +from .string_manip import getlist from .doc_util import get_wrapper_name """!Creates the initial METplus directory structure, diff --git a/metplus/util/met_config.py b/metplus/util/met_config.py index 2e7c54ad00..847eb43e21 100644 --- a/metplus/util/met_config.py +++ b/metplus/util/met_config.py @@ -5,7 +5,8 @@ import os -from .met_util import getlist, get_threshold_via_regex, MISSING_DATA_VALUE +from .string_manip import getlist +from .met_util import get_threshold_via_regex, MISSING_DATA_VALUE from .met_util import remove_quotes as util_remove_quotes from .config_metplus import find_indices_in_config_section diff --git a/metplus/util/met_util.py b/metplus/util/met_util.py index 5b853807fd..aed1e225b1 100644 --- a/metplus/util/met_util.py +++ b/metplus/util/met_util.py @@ -7,14 +7,15 @@ import bz2 import zipfile import struct -from csv import reader from dateutil.relativedelta import relativedelta from pathlib import Path from importlib import import_module +from .string_manip import getlist, getlistint from .string_template_substitution import do_string_sub from .string_template_substitution import parse_template from . import time_util as time_util +from .time_looping import time_generator from .. import get_metplus_version """!@namespace met_util @@ -370,102 +371,6 @@ def is_loop_by_init(config): return None -def get_time_obj(time_from_conf, fmt, clock_time, logger=None, warn=False): - """!Substitute today or now into [INIT/VALID]_[BEG/END] if used - Args: - @param time_from_conf value from [INIT/VALID]_[BEG/END] that - may include now or today tags - @param fmt format of time_from_conf, i.e. %Y%m%d - @param clock_time datetime object for time when execution started - @param logger log object to write error messages - None if not provided - @returns datetime object if successful, None if not - """ - time_str = do_string_sub(time_from_conf, - now=clock_time, - today=clock_time.strftime('%Y%m%d')) - try: - time_t = datetime.datetime.strptime(time_str, fmt) - except ValueError: - error_message = (f"[INIT/VALID]_TIME_FMT ({fmt}) does not match " - f"[INIT/VALID]_[BEG/END] ({time_str})") - if logger: - if warn: - logger.warning(error_message) - else: - logger.error(error_message) - else: - print(f"ERROR: {error_message}") - - return None - - return time_t - -def get_start_end_interval_times(config, warn=False): - """! Reads the METplusConfig object to determine the start, end, and - increment values based on the configuration. Based on the LOOP_BY value, - it will read the INIT_ or VALID_ variables TIME_FMT, BEG, END, and - INCREMENT and use the time format value to parse the other values. - - @param config METplusConfig object to parse - @parm warn (optional) if True, output warnings instead of errors - @returns tuple of start time (datetime), end time (datetime) and - increment (dateutil.relativedelta) or all None values if time info - could not be parsed properly - """ - # set function to send log messages (warning or error) - if warn: - log_function = config.logger.warning - else: - log_function = config.logger.error - - clock_time_obj = datetime.datetime.strptime(config.getstr('config', - 'CLOCK_TIME'), - '%Y%m%d%H%M%S') - use_init = is_loop_by_init(config) - if use_init is None: - return None, None, None - - if use_init: - time_format = config.getstr('config', 'INIT_TIME_FMT') - start_t = config.getraw('config', 'INIT_BEG') - end_t = config.getraw('config', 'INIT_END', start_t) - time_interval = time_util.get_relativedelta( - config.getstr('config', 'INIT_INCREMENT', '60') - ) - else: - time_format = config.getstr('config', 'VALID_TIME_FMT') - start_t = config.getraw('config', 'VALID_BEG') - end_t = config.getraw('config', 'VALID_END', start_t) - time_interval = time_util.get_relativedelta( - config.getstr('config', 'VALID_INCREMENT', '60') - ) - - start_time = get_time_obj(start_t, time_format, - clock_time_obj, config.logger, - warn=warn) - if not start_time: - log_function("Could not format start time") - return None, None, None - - end_time = get_time_obj(end_t, time_format, - clock_time_obj, config.logger, - warn=warn) - if not end_time: - log_function("Could not format end time") - return None, None, None - - if (start_time + time_interval < - start_time + datetime.timedelta(seconds=60)): - log_function('[INIT/VALID]_INCREMENT must be greater than or ' - 'equal to 60 seconds') - return None, None, None - - if start_time > end_time: - log_function("Start time must come before end time") - return None, None, None - - return start_time, end_time, time_interval - def loop_over_times_and_call(config, processes): """! Loop over all run times and call wrappers listed in config @@ -474,79 +379,55 @@ def loop_over_times_and_call(config, processes): @returns list of tuples with all commands run and the environment variables that were set for each """ - use_init = is_loop_by_init(config) - if use_init is None: - return None - - # get start time, end time, and time interval from config - loop_time, end_time, time_interval = get_start_end_interval_times(config) - if not loop_time: - config.logger.error("Could not get [INIT/VALID] time information from configuration file") - return None - # keep track of commands that were run all_commands = [] - while loop_time <= end_time: - log_runtime_banner(loop_time, config, use_init) + for time_input in time_generator(config): if not isinstance(processes, list): processes = [processes] + for process in processes: - input_dict = set_input_dict(loop_time, - config, - use_init, - instance=process.instance) + # if time could not be read, increment errors for each process + if time_input is None: + process.errors += 1 + continue + + log_runtime_banner(config, time_input, process) + add_to_time_input(time_input, + instance=process.instance) process.clear() - process.run_at_time(input_dict) + process.run_at_time(time_input) if process.all_commands: all_commands.extend(process.all_commands) process.all_commands.clear() - loop_time += time_interval - return all_commands -def log_runtime_banner(loop_time, config, use_init): - run_time = loop_time.strftime("%Y-%m-%d %H:%M") - config.logger.info("****************************************") - config.logger.info("* Running METplus") - if use_init: - config.logger.info("* at init time: " + run_time) - else: - config.logger.info("* at valid time: " + run_time) - config.logger.info("****************************************") +def log_runtime_banner(config, time_input, process): + loop_by = time_input['loop_by'] + run_time = time_input[loop_by].strftime("%Y-%m-%d %H:%M") -def set_input_dict(loop_time, config, use_init, instance=None, custom=None): - """! Create input dictionary, set key 'now' to clock time in - YYYYMMDDHHMMSS, set key 'init' to loop_time value if use_init is True, - set key 'valid' to loop_time value if use_init is False, do not set - either if use_init is None + process_name = process.__class__.__name__ + if process.instance: + process_name = f"{process_name}({process.instance})" - @param loop_time datetime object of current runtime - @param config METplusConfig object used to read CLOCK_TIME - @param use_init True if looping by init, False if looping by valid, - None otherwise - """ - input_dict = {} - clock_time_obj = datetime.datetime.strptime(config.getstr('config', - 'CLOCK_TIME'), - '%Y%m%d%H%M%S') - input_dict['now'] = clock_time_obj + config.logger.info("****************************************") + config.logger.info(f"* Running METplus {process_name}") + config.logger.info(f"* at {loop_by} time: {run_time}") + config.logger.info("****************************************") - if use_init: - input_dict['init'] = loop_time - elif use_init is not None: - input_dict['valid'] = loop_time +def add_to_time_input(time_input, clock_time=None, instance=None, custom=None): + if clock_time: + clock_dt = datetime.datetime.strptime(clock_time, '%Y%m%d%H%M%S') + time_input['now'] = clock_dt # if instance is set, use that value, otherwise use empty string - input_dict['instance'] = instance if instance else '' + time_input['instance'] = instance if instance else '' - # if custom is specified, set it, otherwise leave it unset so it can be - # set within the wrapper + # if custom is specified, set it + # otherwise leave it unset so it can be set within the wrapper if custom: - input_dict['custom'] = custom - - return input_dict + time_input['custom'] = custom def get_lead_sequence(config, input_dict=None, wildcard_if_empty=False): """!Get forecast lead list from LEAD_SEQ or compute it from INIT_SEQ. @@ -895,184 +776,6 @@ def prune_empty(output_dir, logger): "...removing") os.rmdir(full_dir) -def handle_begin_end_incr(list_str): - """!Check for instances of begin_end_incr() in the input string and evaluate as needed - Args: - @param list_str string that contains a comma separated list - @returns string that has list expanded""" - - matches = begin_end_incr_findall(list_str) - - for match in matches: - item_list = begin_end_incr_evaluate(match) - if item_list: - list_str = list_str.replace(match, ','.join(item_list)) - - return list_str - -def begin_end_incr_findall(list_str): - """!Find all instances of begin_end_incr in list string - Args: - @param list_str string that contains a comma separated list - @returns list of strings that have begin_end_incr() characters""" - # remove space around commas (again to make sure) - # this makes the regex slightly easier because we don't have to include - # as many \s* instances in the regex string - list_str = re.sub(r'\s*,\s*', ',', list_str) - - # find begin_end_incr and any text before and after that are not a comma - # [^,\s]* evaluates to any character that is not a comma or space - return re.findall(r"([^,]*begin_end_incr\(\s*-?\d*,-?\d*,-*\d*,?\d*\s*\)[^,]*)", - list_str) - -def begin_end_incr_evaluate(item): - """!Expand begin_end_incr() items into a list of values - Args: - @param item string containing begin_end_incr() tag with - possible text before and after - @returns list of items expanded from begin_end_incr - """ - match = re.match(r"^(.*)begin_end_incr\(\s*(-*\d*),(-*\d*),(-*\d*),?(\d*)\s*\)(.*)$", - item) - if match: - before = match.group(1).strip() - after = match.group(6).strip() - start = int(match.group(2)) - end = int(match.group(3)) - step = int(match.group(4)) - precision = match.group(5).strip() - - if start <= end: - int_list = range(start, end+1, step) - else: - int_list = range(start, end-1, step) - - out_list = [] - for int_values in int_list: - out_str = str(int_values) - - if precision: - out_str = out_str.zfill(int(precision)) - - out_list.append(f"{before}{out_str}{after}") - - return out_list - - return None - -def fix_list(item_list): - item_list = fix_list_helper(item_list, '(') - item_list = fix_list_helper(item_list, '[') - return item_list - -def fix_list_helper(item_list, type): - if type == '(': - close_regex = r"[^(]+\).*" - open_regex = r".*\([^)]*$" - elif type == '[': - close_regex = r"[^\[]+\].*" - open_regex = r".*\[[^\]]*$" - else: - return item_list - - # combine items that had a comma between ()s or []s - fixed_list = [] - incomplete_item = None - found_close = False - for index, item in enumerate(item_list): - # if we have found an item that ends with ( but - if incomplete_item: - # check if item has ) before ( - match = re.match(close_regex, item) - if match: - # add rest of text, add it to output list, then reset incomplete_item - incomplete_item += ',' + item - found_close = True - else: - # if not ) before (, add text and continue - incomplete_item += ',' + item - - match = re.match(open_regex, item) - # if we find ( without ) after it - if match: - # if we are still putting together an item, append comma and new item - if incomplete_item: - if not found_close: - incomplete_item += ',' + item - # if not, start new incomplete item to put together - else: - incomplete_item = item - - found_close = False - # if we don't find ( without ) - else: - # if we are putting together item, we can add to the output list and reset incomplete_item - if incomplete_item: - if found_close: - fixed_list.append(incomplete_item) - incomplete_item = None - # if we are not within brackets and we found no brackets, add item to output list - else: - fixed_list.append(item) - - return fixed_list - -def getlist(list_str, expand_begin_end_incr=True): - """! Returns a list of string elements from a comma - separated string of values. - This function MUST also return an empty list [] if s is '' empty. - This function is meant to handle these possible or similar inputs: - AND return a clean list with no surrounding spaces or trailing - commas in the elements. - '4,4,2,4,2,4,2, ' or '4,4,2,4,2,4,2 ' or - '4, 4, 4, 4, ' or '4, 4, 4, 4 ' - Note: getstr on an empty variable (EMPTY_VAR = ) in - a conf file returns '' an empty string. - - @param list_str the string being converted to a list. - @returns list of strings formatted properly and expanded as needed - """ - if not list_str: - return [] - - # FIRST remove surrounding comma, and spaces, form the string. - list_str = list_str.strip(';[] ').strip().strip(',').strip() - - # remove space around commas - list_str = re.sub(r'\s*,\s*', ',', list_str) - - # option to not evaluate begin_end_incr - if expand_begin_end_incr: - list_str = handle_begin_end_incr(list_str) - - # use csv reader to divide comma list while preserving strings with comma - # convert the csv reader to a list and get first item (which is the whole list) - item_list = list(reader([list_str], escapechar='\\'))[0] - - item_list = fix_list(item_list) - - return item_list - -def getlistfloat(list_str): - """!Get list and convert all values to float - Args: - @param list_str the string being converted to a list. - @returns list of floats - """ - list_str = getlist(list_str) - list_str = [float(i) for i in list_str] - return list_str - -def getlistint(list_str): - """!Get list and convert all values to int - Args: - @param list_str the string being converted to a list. - @returns list of ints - """ - list_str = getlist(list_str) - list_str = [int(i) for i in list_str] - return list_str - def camel_to_underscore(camel): """! Change camel case notation to underscore notation, i.e. GridStatWrapper to grid_stat_wrapper Multiple capital letters are excluded, i.e. PCPCombineWrapper to pcp_combine_wrapper diff --git a/metplus/util/string_manip.py b/metplus/util/string_manip.py new file mode 100644 index 0000000000..f6326d50fb --- /dev/null +++ b/metplus/util/string_manip.py @@ -0,0 +1,189 @@ +""" +Program Name: string_manip.py +Contact(s): George McCabe +Description: METplus utility to handle string manipulation +""" + +import re +from csv import reader + +def getlist(list_str, expand_begin_end_incr=True): + """! Returns a list of string elements from a comma + separated string of values. + This function MUST also return an empty list [] if s is '' empty. + This function is meant to handle these possible or similar inputs: + AND return a clean list with no surrounding spaces or trailing + commas in the elements. + '4,4,2,4,2,4,2, ' or '4,4,2,4,2,4,2 ' or + '4, 4, 4, 4, ' or '4, 4, 4, 4 ' + Note: getstr on an empty variable (EMPTY_VAR = ) in + a conf file returns '' an empty string. + + @param list_str the string being converted to a list. + @returns list of strings formatted properly and expanded as needed + """ + if not list_str: + return [] + + # FIRST remove surrounding comma, and spaces, form the string. + list_str = list_str.strip(';[] ').strip().strip(',').strip() + + # remove space around commas + list_str = re.sub(r'\s*,\s*', ',', list_str) + + # option to not evaluate begin_end_incr + if expand_begin_end_incr: + list_str = _handle_begin_end_incr(list_str) + + # use csv reader to divide comma list while preserving strings with comma + # convert the csv reader to a list and get first item + # (which is the whole list) + item_list = list(reader([list_str], escapechar='\\'))[0] + + item_list = _fix_list(item_list) + + return item_list + +def getlistint(list_str): + """! Get list and convert all values to int + + @param list_str the string being converted to a list. + @returns list of ints + """ + list_str = getlist(list_str) + list_str = [int(i) for i in list_str] + return list_str + + +def _handle_begin_end_incr(list_str): + """! Check for instances of begin_end_incr() in the input string and + evaluate as needed + + @param list_str string that contains a comma separated list + @returns string that has list expanded + """ + + matches = _begin_end_incr_findall(list_str) + + for match in matches: + item_list = _begin_end_incr_evaluate(match) + if item_list: + list_str = list_str.replace(match, ','.join(item_list)) + + return list_str + +def _begin_end_incr_findall(list_str): + """! Find all instances of begin_end_incr in list string + + @param list_str string that contains a comma separated list + @returns list of strings that have begin_end_incr() characters + """ + # remove space around commas (again to make sure) + # this makes the regex slightly easier because we don't have to include + # as many \s* instances in the regex string + list_str = re.sub(r'\s*,\s*', ',', list_str) + + # find begin_end_incr and any text before and after that are not a comma + # [^,\s]* evaluates to any character that is not a comma or space + return re.findall( + r"([^,]*begin_end_incr\(\s*-?\d*,-?\d*,-*\d*,?\d*\s*\)[^,]*)", + list_str + ) + +def _begin_end_incr_evaluate(item): + """! Expand begin_end_incr() items into a list of values + + @param item string containing begin_end_incr() tag with + possible text before and after + @returns list of items expanded from begin_end_incr + """ + match = re.match( + r"^(.*)begin_end_incr\(\s*(-*\d*),(-*\d*),(-*\d*),?(\d*)\s*\)(.*)$", + item + ) + if match: + before = match.group(1).strip() + after = match.group(6).strip() + start = int(match.group(2)) + end = int(match.group(3)) + step = int(match.group(4)) + precision = match.group(5).strip() + + if start <= end: + int_list = range(start, end+1, step) + else: + int_list = range(start, end-1, step) + + out_list = [] + for int_values in int_list: + out_str = str(int_values) + + if precision: + out_str = out_str.zfill(int(precision)) + + out_list.append(f"{before}{out_str}{after}") + + return out_list + + return None + +def _fix_list(item_list): + item_list = _fix_list_helper(item_list, '(') + item_list = _fix_list_helper(item_list, '[') + return item_list + +def _fix_list_helper(item_list, type): + if type == '(': + close_regex = r"[^(]+\).*" + open_regex = r".*\([^)]*$" + elif type == '[': + close_regex = r"[^\[]+\].*" + open_regex = r".*\[[^\]]*$" + else: + return item_list + + # combine items that had a comma between ()s or []s + fixed_list = [] + incomplete_item = None + found_close = False + for index, item in enumerate(item_list): + # if we have found an item that ends with ( but + if incomplete_item: + # check if item has ) before ( + match = re.match(close_regex, item) + if match: + # add rest of text, add it to output list, + # then reset incomplete_item + incomplete_item += ',' + item + found_close = True + else: + # if not ) before (, add text and continue + incomplete_item += ',' + item + + match = re.match(open_regex, item) + # if we find ( without ) after it + if match: + # if we are still putting together an item, + # append comma and new item + if incomplete_item: + if not found_close: + incomplete_item += ',' + item + # if not, start new incomplete item to put together + else: + incomplete_item = item + + found_close = False + # if we don't find ( without ) + else: + # if we are putting together item, we can add to the + # output list and reset incomplete_item + if incomplete_item: + if found_close: + fixed_list.append(incomplete_item) + incomplete_item = None + # if we are not within brackets and we found no brackets, + # add item to output list + else: + fixed_list.append(item) + + return fixed_list diff --git a/metplus/util/time_looping.py b/metplus/util/time_looping.py new file mode 100644 index 0000000000..0632b17639 --- /dev/null +++ b/metplus/util/time_looping.py @@ -0,0 +1,164 @@ +from datetime import datetime, timedelta + +from .string_manip import getlist +from .time_util import get_relativedelta +from .string_template_substitution import do_string_sub + +def time_generator(config): + """! Generator used to read METplusConfig variables for time looping + + @param METplusConfig object to read + @returns None if not enough information is available on config. + Yields the next run time dictionary or None if something went wrong + """ + # determine INIT or VALID prefix + prefix = get_time_prefix(config) + if not prefix: + yield None + return + + # get clock time of when the run started + clock_dt = datetime.strptime( + config.getstr('config', 'CLOCK_TIME'), + '%Y%m%d%H%M%S' + ) + + time_format = config.getraw('config', f'{prefix}_TIME_FMT', '') + if not time_format: + config.logger.error(f'Could not read {prefix}_TIME_FMT') + yield None + return + + # check for [INIT/VALID]_LIST and use that list if set + if config.has_option('config', f'{prefix}_LIST'): + time_list = getlist(config.getraw('config', f'{prefix}_LIST')) + if not time_list: + config.logger.error(f"Could not read {prefix}_LIST") + yield None + return + + for time_string in time_list: + current_dt = _get_current_dt(time_string, + time_format, + clock_dt, + config.logger) + if not current_dt: + yield None + + time_info = _create_time_input_dict(prefix, current_dt, clock_dt) + yield time_info + + return + + # if list is not provided, use _BEG, _END, and _INCREMENT + start_string = config.getraw('config', f'{prefix}_BEG') + end_string = config.getraw('config', f'{prefix}_END', start_string) + time_interval = get_relativedelta( + config.getstr('config', f'{prefix}_INCREMENT', '60') + ) + + start_dt = _get_current_dt(start_string, + time_format, + clock_dt, + config.logger) + + end_dt = _get_current_dt(end_string, + time_format, + clock_dt, + config.logger) + + if not _validate_time_values(start_dt, + end_dt, + time_interval, + prefix, + config.logger): + yield None + return + + current_dt = start_dt + while current_dt <= end_dt: + time_info = _create_time_input_dict(prefix, current_dt, clock_dt) + yield time_info + + current_dt += time_interval + +def _validate_time_values(start_dt, end_dt, time_interval, prefix, logger): + if not start_dt: + logger.error(f"Could not read {prefix}_BEG") + return False + + if not end_dt: + logger.error(f"Could not read {prefix}_END") + return False + + # check that time increment is at least 60 seconds + if (start_dt + time_interval < + start_dt + timedelta(seconds=60)): + logger.error(f'{prefix}_INCREMENT must be greater than or ' + 'equal to 60 seconds') + return False + + if start_dt > end_dt: + logger.error(f"{prefix}_BEG must come after {prefix}_END ") + return False + + return True + +def _create_time_input_dict(prefix, current_dt, clock_dt): + return { + 'loop_by': prefix.lower(), + prefix.lower(): current_dt, + 'now': clock_dt, + } + +def get_time_prefix(config): + """! Read the METplusConfig object and determine the prefix for the time + looping variables. + + @param config METplusConfig object to read + @returns string 'INIT' if looping by init time, 'VALID' if looping by + valid time, or None if not enough information was found in the config + """ + loop_by = config.getstr('config', 'LOOP_BY', '').upper() + if loop_by in ['INIT', 'RETRO']: + return 'INIT' + + if loop_by in ['VALID', 'REALTIME']: + return 'VALID' + + # check for legacy variable LOOP_BY_INIT if LOOP_BY is not set properly + if config.has_option('config', 'LOOP_BY_INIT'): + if config.getbool('config', 'LOOP_BY_INIT'): + return 'INIT' + + return 'VALID' + + # report an error if time prefix could not be determined + config.logger.error('MUST SET LOOP_BY to VALID, INIT, RETRO, or REALTIME') + return None + +def _get_current_dt(time_string, time_format, clock_dt, logger): + """! Use time format to get datetime object from time string, substituting + values for today or now template tags if specified. + + @param time_string string value read from the config that + may include now or today tags + @param time_format format of time_string, i.e. %Y%m%d + @param clock_dt datetime object for time when execution started + @returns datetime object if successful, None if not + """ + subbed_time_string = do_string_sub( + time_string, + now=clock_dt, + today=clock_dt.strftime('%Y%m%d') + ) + try: + current_dt = datetime.strptime(subbed_time_string, time_format) + except ValueError: + logger.error( + f'Could not format time string ({time_string}) using ' + f'time format ({time_format})' + ) + return None + + return current_dt diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index d62707317b..3835d8b130 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -18,6 +18,7 @@ import re from .command_runner import CommandRunner +from ..util import getlist from ..util import met_util as util from ..util import do_string_sub, ti_calculate, get_seconds_from_string from ..util import get_time_from_file @@ -623,7 +624,7 @@ def find_exact_file(self, level, data_type, time_info, mandatory=True, # check if there is a list of files provided in the template # process each template in the list (or single template) - template_list = util.getlist(input_template) + template_list = getlist(input_template) # return None if a list is provided for a wrapper that doesn't allow # multiple files to be processed @@ -1400,31 +1401,6 @@ def run_all_times(self): call METplus wrapper for each time""" return util.loop_over_times_and_call(self.config, self) - def set_time_dict_for_single_runtime(self): - # get clock time from start of execution for input time dictionary - clock_time_obj = datetime.strptime(self.config.getstr('config', - 'CLOCK_TIME'), - '%Y%m%d%H%M%S') - - # get start run time and set INPUT_TIME_DICT - time_info = {'now': clock_time_obj} - start_time, _, _ = util.get_start_end_interval_times(self.config) - if start_time: - # set init or valid based on LOOP_BY - use_init = util.is_loop_by_init(self.config) - if use_init is None: - return None - elif use_init: - time_info['init'] = start_time - else: - time_info['valid'] = start_time - else: - self.config.logger.error("Could not get [INIT/VALID] time " - "information from configuration file") - return None - - return time_info - @staticmethod def format_met_config_dict(c_dict, name, keys=None): """! Return formatted dictionary named with any if they @@ -1600,7 +1576,7 @@ def read_climo_file_name(self, climo_type): # if dir is set and not python embedding, # prepend it to each template in list if input_dir and input_template not in util.PYTHON_EMBEDDING_TYPES: - template_list = util.getlist(input_template) + template_list = getlist(input_template) for index, template in enumerate(template_list): template_list[index] = os.path.join(input_dir, template) @@ -1811,23 +1787,3 @@ def get_config_file(self, default_config_file=None): return get_wrapped_met_config_file(self.config, self.app_name, default_config_file) - - def get_start_time_input_dict(self): - """! Get the first run time specified in config. Used if only running - the wrapper once (LOOP_ORDER = processes). - - @returns dictionary containing time information for first run time - """ - use_init = util.is_loop_by_init(self.config) - if use_init is None: - self.log_error('Could not read time info') - return None - - - start_time, _, _ = util.get_start_end_interval_times(self.config) - if start_time is None: - self.log_error("Could not get start time") - return None - - input_dict = util.set_input_dict(start_time, self.config, use_init) - return input_dict diff --git a/metplus/wrappers/cyclone_plotter_wrapper.py b/metplus/wrappers/cyclone_plotter_wrapper.py index b05e76ded2..e401c9bd88 100644 --- a/metplus/wrappers/cyclone_plotter_wrapper.py +++ b/metplus/wrappers/cyclone_plotter_wrapper.py @@ -39,6 +39,7 @@ from ..util import met_util as util from ..util import do_string_sub +from ..util import time_generator, add_to_time_input from . import CommandBuilder @@ -65,23 +66,12 @@ def __init__(self, config, instance=None, config_overrides=None): 'CYCLONE_PLOTTER_INIT_DATE') self.init_hr = self.config.getraw('config', 'CYCLONE_PLOTTER_INIT_HR') - init_time_fmt = self.config.getstr('config', 'INIT_TIME_FMT', '') - - if init_time_fmt: - clock_time = datetime.datetime.strptime( - self.config.getstr('config', - 'CLOCK_TIME'), - '%Y%m%d%H%M%S' - ) - - init_beg = self.config.getraw('config', 'INIT_BEG') - if init_beg: - init_beg_dt = util.get_time_obj(init_beg, - init_time_fmt, - clock_time, - logger=self.logger) - self.init_date = do_string_sub(self.init_date, init=init_beg_dt) - self.init_hr = do_string_sub(self.init_hr, init=init_beg_dt) + # attempt to get first runtime from config + # if successful, substitute time values into init date and hour + time_input = next(time_generator(self.config)) + if time_input is not None: + self.init_date = do_string_sub(self.init_date, **time_input) + self.init_hr = do_string_sub(self.init_hr, **time_input) self.model = self.config.getstr('config', 'CYCLONE_PLOTTER_MODEL') self.title = self.config.getstr('config', diff --git a/metplus/wrappers/extract_tiles_wrapper.py b/metplus/wrappers/extract_tiles_wrapper.py index 6dd37a9684..c04d995806 100755 --- a/metplus/wrappers/extract_tiles_wrapper.py +++ b/metplus/wrappers/extract_tiles_wrapper.py @@ -290,35 +290,38 @@ def use_tc_stat_input(self, storm_dict, idx_dict): def use_mtd_input(self, object_dict, idx_dict): """! Find lat/lons in MTD input file and create tiles from locations. - @param input_path path to MTD file to process + @param object_dict dictionary of MTD object data @param idx_dict dictionary with header names as keys and the index of those names as values. """ indices = self.get_object_indices(object_dict.keys()) if not indices: - self.log_error(f"No non-zero OBJECT_CAT found") + self.logger.warning(f"No non-zero OBJECT_CAT found") return # loop over corresponding CF### and CO### lines for index in indices: fcst_data_list = self.get_cluster_data(object_dict[f'CF{index}'], - idx_dict) + idx_dict) obs_data_list = self.get_cluster_data(object_dict[f'CO{index}'], - idx_dict) + idx_dict) track_data = {} - for fcst_data, obs_data in zip(fcst_data_list, obs_data_list): - if fcst_data.get('FCST_VALID') != obs_data.get('FCST_VALID'): - self.log_error("Time mismatch in valid time between " - f"CF{index} and CO{index}: " - f"({fcst_data.get('FCST_VALID')} vs " - f"{obs_data.get('FCST_VALID')}). " - "Wrapper assumes fcst and obs cluster data " - "are in the same order.") - return + # loop through fcst data and find obs data that matches the time + for fcst_data in fcst_data_list: + fcst_lead = fcst_data.get('FCST_LEAD') + fcst_valid = fcst_data.get('FCST_VALID') + + obs_data = [item for item in obs_data_list + if item.get('FCST_LEAD') == fcst_lead and + item.get('FCST_VALID') == fcst_valid] + + # skip if no obs data with the same fcst lead and valid time + if not obs_data: + continue track_data['FCST'] = fcst_data - track_data['OBS'] = obs_data + track_data['OBS'] = obs_data[0] time_info = ( self.set_time_info_from_track_data(track_data['FCST']) @@ -361,17 +364,17 @@ def get_object_indices(object_cats): indices = set() for key in object_cats: match = re.match(r'CF(\d+)', key) - if match: + # only use non-zero (000) objects + if match and int(match.group(1)) != 0: indices.add(match.group(1)) indices = sorted(list(indices)) - # if no indices were found or there is 1 and it is zero, return None - if not indices or (len(indices) == 1 and int(indices[0]) == 0): + # if no indices were found, return None + if not indices: return None return indices - def call_regrid_data_plane(self, time_info, track_data, input_type): # set var list from config using time info var_list = util.sub_var_list(self.c_dict['VAR_LIST_TEMP'], diff --git a/metplus/wrappers/gen_vx_mask_wrapper.py b/metplus/wrappers/gen_vx_mask_wrapper.py index 2c852ed33f..ac5459ec1d 100755 --- a/metplus/wrappers/gen_vx_mask_wrapper.py +++ b/metplus/wrappers/gen_vx_mask_wrapper.py @@ -12,6 +12,7 @@ import os +from ..util import getlist from ..util import met_util as util from ..util import time_util from . import CommandBuilder @@ -53,16 +54,18 @@ def create_c_dict(self): c_dict['MASK_INPUT_DIR'] = self.config.getdir('GEN_VX_MASK_INPUT_MASK_DIR', '') - c_dict['MASK_INPUT_TEMPLATES'] = util.getlist(self.config.getraw('filename_templates', - 'GEN_VX_MASK_INPUT_MASK_TEMPLATE')) + c_dict['MASK_INPUT_TEMPLATES'] = getlist( + self.config.getraw('config', 'GEN_VX_MASK_INPUT_MASK_TEMPLATE') + ) if not c_dict['MASK_INPUT_TEMPLATES']: self.log_error("Must set GEN_VX_MASK_INPUT_MASK_TEMPLATE to run GenVxMask wrapper") self.isOK = False # optional arguments - c_dict['COMMAND_OPTIONS'] = util.getlist(self.config.getraw('config', - 'GEN_VX_MASK_OPTIONS')) + c_dict['COMMAND_OPTIONS'] = getlist( + self.config.getraw('config', 'GEN_VX_MASK_OPTIONS') + ) # if no options were specified, set to a list with an empty string if not c_dict['COMMAND_OPTIONS']: diff --git a/metplus/wrappers/make_plots_wrapper.py b/metplus/wrappers/make_plots_wrapper.py index b73d660028..43d3183633 100755 --- a/metplus/wrappers/make_plots_wrapper.py +++ b/metplus/wrappers/make_plots_wrapper.py @@ -18,6 +18,7 @@ import datetime import itertools +from ..util import getlist from ..util import met_util as util from ..util import parse_var_list from . import CommandBuilder @@ -115,56 +116,56 @@ def create_c_dict(self): c_dict['VALID_END'] = self.config.getstr('config', 'VALID_END', '') c_dict['INIT_BEG'] = self.config.getstr('config', 'INIT_BEG', '') c_dict['INIT_END'] = self.config.getstr('config', 'INIT_END', '') - c_dict['GROUP_LIST_ITEMS'] = util.getlist( + c_dict['GROUP_LIST_ITEMS'] = getlist( self.config.getstr('config', 'GROUP_LIST_ITEMS') ) - c_dict['LOOP_LIST_ITEMS'] = util.getlist( + c_dict['LOOP_LIST_ITEMS'] = getlist( self.config.getstr('config', 'LOOP_LIST_ITEMS') ) c_dict['VAR_LIST'] = parse_var_list(self.config) - c_dict['MODEL_LIST'] = util.getlist( + c_dict['MODEL_LIST'] = getlist( self.config.getstr('config', 'MODEL_LIST', '') ) - c_dict['DESC_LIST'] = util.getlist( + c_dict['DESC_LIST'] = getlist( self.config.getstr('config', 'DESC_LIST', '') ) - c_dict['FCST_LEAD_LIST'] = util.getlist( + c_dict['FCST_LEAD_LIST'] = getlist( self.config.getstr('config', 'FCST_LEAD_LIST', '') ) - c_dict['OBS_LEAD_LIST'] = util.getlist( + c_dict['OBS_LEAD_LIST'] = getlist( self.config.getstr('config', 'OBS_LEAD_LIST', '') ) - c_dict['FCST_VALID_HOUR_LIST'] = util.getlist( + c_dict['FCST_VALID_HOUR_LIST'] = getlist( self.config.getstr('config', 'FCST_VALID_HOUR_LIST', '') ) - c_dict['FCST_INIT_HOUR_LIST'] = util.getlist( + c_dict['FCST_INIT_HOUR_LIST'] = getlist( self.config.getstr('config', 'FCST_INIT_HOUR_LIST', '') ) - c_dict['OBS_VALID_HOUR_LIST'] = util.getlist( + c_dict['OBS_VALID_HOUR_LIST'] = getlist( self.config.getstr('config', 'OBS_VALID_HOUR_LIST', '') ) - c_dict['OBS_INIT_HOUR_LIST'] = util.getlist( + c_dict['OBS_INIT_HOUR_LIST'] = getlist( self.config.getstr('config', 'OBS_INIT_HOUR_LIST', '') ) - c_dict['VX_MASK_LIST'] = util.getlist( + c_dict['VX_MASK_LIST'] = getlist( self.config.getstr('config', 'VX_MASK_LIST', '') ) - c_dict['INTERP_MTHD_LIST'] = util.getlist( + c_dict['INTERP_MTHD_LIST'] = getlist( self.config.getstr('config', 'INTERP_MTHD_LIST', '') ) - c_dict['INTERP_PNTS_LIST'] = util.getlist( + c_dict['INTERP_PNTS_LIST'] = getlist( self.config.getstr('config', 'INTERP_PNTS_LIST', '') ) - c_dict['COV_THRESH_LIST'] = util.getlist( + c_dict['COV_THRESH_LIST'] = getlist( self.config.getstr('config', 'COV_THRESH_LIST', '') ) - c_dict['ALPHA_LIST'] = util.getlist( + c_dict['ALPHA_LIST'] = getlist( self.config.getstr('config', 'ALPHA_LIST', '') ) - c_dict['LINE_TYPE_LIST'] = util.getlist( + c_dict['LINE_TYPE_LIST'] = getlist( self.config.getstr('config', 'LINE_TYPE_LIST', '') ) - c_dict['USER_SCRIPT_LIST'] = util.getlist( + c_dict['USER_SCRIPT_LIST'] = getlist( self.config.getstr('config', 'MAKE_PLOTS_USER_SCRIPT_LIST', '') ) c_dict['VERIF_CASE'] = self.config.getstr('config', @@ -198,7 +199,7 @@ def create_c_dict(self): "MAKE_PLOTS_VERIF_TYPE, or " "MAKE_PLOTS_USER_SCRIPT_LIST") - c_dict['STATS_LIST'] = util.getlist( + c_dict['STATS_LIST'] = getlist( self.config.getstr('config', 'MAKE_PLOTS_STATS_LIST', '') ) c_dict['AVERAGE_METHOD'] = self.config.getstr( diff --git a/metplus/wrappers/pb2nc_wrapper.py b/metplus/wrappers/pb2nc_wrapper.py index 5e8622a2bd..f571934f3c 100755 --- a/metplus/wrappers/pb2nc_wrapper.py +++ b/metplus/wrappers/pb2nc_wrapper.py @@ -13,6 +13,7 @@ import os import re +from ..util import getlistint from ..util import met_util as util from ..util import time_util from ..util import do_string_sub @@ -60,9 +61,9 @@ def create_c_dict(self): 'LOG_PB2NC_VERBOSITY', c_dict['VERBOSITY']) - c_dict['OFFSETS'] = util.getlistint(self.config.getstr('config', - 'PB2NC_OFFSETS', - '0')) + c_dict['OFFSETS'] = getlistint(self.config.getstr('config', + 'PB2NC_OFFSETS', + '0')) # Directories # these are optional because users can specify full file path diff --git a/metplus/wrappers/point_stat_wrapper.py b/metplus/wrappers/point_stat_wrapper.py index 02c97debee..c47673a5a4 100755 --- a/metplus/wrappers/point_stat_wrapper.py +++ b/metplus/wrappers/point_stat_wrapper.py @@ -12,6 +12,7 @@ import os +from ..util import getlistint from ..util import met_util as util from ..util import time_util from ..util import do_string_sub @@ -93,7 +94,7 @@ def create_c_dict(self): c_dict['VERBOSITY']) ) c_dict['ALLOW_MULTIPLE_FILES'] = True - c_dict['OFFSETS'] = util.getlistint( + c_dict['OFFSETS'] = getlistint( self.config.getstr('config', 'POINT_STAT_OFFSETS', '0') diff --git a/metplus/wrappers/runtime_freq_wrapper.py b/metplus/wrappers/runtime_freq_wrapper.py index eccc0604a5..105bc552e9 100755 --- a/metplus/wrappers/runtime_freq_wrapper.py +++ b/metplus/wrappers/runtime_freq_wrapper.py @@ -15,9 +15,10 @@ from ..util import time_util from . import CommandBuilder -from ..util import do_string_sub, get_start_end_interval_times, set_input_dict +from ..util import do_string_sub from ..util import log_runtime_banner, get_lead_sequence, is_loop_by_init from ..util import skip_time, getlist +from ..util import time_generator, add_to_time_input '''!@namespace RuntimeFreqWrapper @brief Parent class for wrappers that run over a grouping of times @@ -57,13 +58,6 @@ def create_c_dict(self): '').upper() ) - # get runtime information to obtain all input files - start, end, interval = get_start_end_interval_times(self.config, - warn=True) - c_dict['START_TIME'] = start - c_dict['END_TIME'] = end - c_dict['TIME_INTERVAL'] = interval - return c_dict def get_input_templates(self, c_dict): @@ -108,15 +102,6 @@ def run_all_times(self): f" {', '.join(self.FREQ_OPTIONS)}") return None - # if looping over init/valid time, - # check that the time config variables can be read correctly - if self.c_dict['RUNTIME_FREQ'] == 'RUN_ONCE_PER_INIT_OR_VALID': - - if not self.c_dict['START_TIME']: - self.log_error("Could not get [INIT/VALID] time information" - "from configuration file") - return None - # if not running once for each runtime and loop order is not set to # 'processes' report an error if self.c_dict['RUNTIME_FREQ'] != 'RUN_ONCE_FOR_EACH': @@ -159,53 +144,44 @@ def run_all_times_custom(self, custom): def run_once(self, custom): self.logger.debug("Running once for all files") - # create input dictionary and get 'now' item - input_dict = set_input_dict(loop_time=None, - config=self.config, - use_init=None, - instance=self.instance, - custom=custom) + # create input dictionary and set clock time, instance, and custom + time_input = {} + add_to_time_input(time_input, + clock_time=self.config.getstr('config', + 'CLOCK_TIME'), + instance=self.instance, + custom=custom) # set other time items to wildcard to find all files - input_dict['init'] = '*' - input_dict['valid'] = '*' - input_dict['lead'] = '*' + time_input['init'] = '*' + time_input['valid'] = '*' + time_input['lead'] = '*' - return self.run_at_time_once(input_dict) + return self.run_at_time_once(time_input) def run_once_per_init_or_valid(self, custom): - use_init = is_loop_by_init(self.config) - if use_init is None: - return False - - # log which time type to loop over - if use_init: - init_or_valid = 'init' - else: - init_or_valid = 'valid' - self.logger.debug(f"Running once for each {init_or_valid} time") + self.logger.debug(f"Running once for each init/valid time") success = True - loop_time = self.c_dict['START_TIME'] - while loop_time <= self.c_dict['END_TIME']: - log_runtime_banner(loop_time, self.config, use_init) - input_dict = set_input_dict(loop_time, - self.config, - use_init, - instance=self.instance, - custom=custom) - - if 'init' in input_dict: - input_dict['valid'] = '*' - elif 'valid' in input_dict: - input_dict['init'] = '*' - - input_dict['lead'] = '*' - - if not self.run_at_time_once(input_dict): + for time_input in time_generator(self.config): + if time_input is None: success = False + continue + + log_runtime_banner(self.config, time_input, self) + add_to_time_input(time_input, + instance=self.instance, + custom=custom) - loop_time += self.c_dict['TIME_INTERVAL'] + if 'init' in time_input: + time_input['valid'] = '*' + elif 'valid' in time_input: + time_input['init'] = '*' + + time_input['lead'] = '*' + + if not self.run_at_time_once(time_input): + success = False return success @@ -218,17 +194,19 @@ def run_once_per_lead(self, custom): # create input dict and only set 'now' item # create a new dictionary each iteration in case the function # that it is passed into modifies it - input_dict = set_input_dict(loop_time=None, - config=self.config, - use_init=None, - instance=self.instance, - custom=custom) + time_input = {} + add_to_time_input(time_input, + clock_time=self.config.getstr('config', + 'CLOCK_TIME'), + instance=self.instance, + custom=custom) + # add forecast lead - input_dict['lead'] = lead - input_dict['init'] = '*' - input_dict['valid'] = '*' + time_input['lead'] = lead + time_input['init'] = '*' + time_input['valid'] = '*' - if not self.run_at_time_once(input_dict): + if not self.run_at_time_once(time_input): success = False return success @@ -237,8 +215,8 @@ def run_at_time(self, input_dict): """! Runs the command for a given run time. This function loops over the list of forecast leads and list of custom loops and runs once for each combination - Args: - @param input_dict dictionary containing time information + + @param input_dict dictionary containing time information """ for custom_string in self.c_dict['CUSTOM_LOOP_LIST']: if custom_string: @@ -283,37 +261,29 @@ def get_all_files(self, custom=None): i.e. fcst or obs, and the value is a list of files that fit in that category """ - use_init = is_loop_by_init(self.config) - if use_init is None: - return False - self.logger.debug("Finding all input files") all_files = [] - # if start time is not set, don't loop - if not self.c_dict.get('START_TIME'): - return False - # loop over all init/valid times - loop_time = self.c_dict['START_TIME'] - while loop_time <= self.c_dict['END_TIME']: - input_dict = set_input_dict(loop_time, - self.config, - use_init, - instance=self.instance, - custom=custom) + for time_input in time_generator(self.config): + if time_input is None: + return False + + add_to_time_input(time_input, + instance=self.instance, + custom=custom) # loop over all forecast leads wildcard_if_empty = self.c_dict.get('WILDCARD_LEAD_IF_EMPTY', False) lead_seq = get_lead_sequence(self.config, - input_dict, + time_input, wildcard_if_empty=wildcard_if_empty) for lead in lead_seq: - input_dict['lead'] = lead + time_input['lead'] = lead # set current lead time config and environment variables - time_info = time_util.ti_calculate(input_dict) + time_info = time_util.ti_calculate(time_input) if skip_time(time_info, self.c_dict.get('SKIP_TIMES')): continue @@ -325,8 +295,6 @@ def get_all_files(self, custom=None): else: all_files.append(file_dict) - loop_time += self.c_dict['TIME_INTERVAL'] - if not all_files: return False diff --git a/metplus/wrappers/series_analysis_wrapper.py b/metplus/wrappers/series_analysis_wrapper.py index 429b139c39..8a16f4e2dd 100755 --- a/metplus/wrappers/series_analysis_wrapper.py +++ b/metplus/wrappers/series_analysis_wrapper.py @@ -22,12 +22,14 @@ WRAPPER_CANNOT_RUN = True EXCEPTION_ERR = err_msg +from ..util import getlist from ..util import met_util as util from ..util import do_string_sub, parse_template -from ..util import get_lead_sequence, get_lead_sequence_groups, set_input_dict +from ..util import get_lead_sequence, get_lead_sequence_groups from ..util import ti_get_hours_from_lead, ti_get_seconds_from_lead from ..util import ti_get_lead_string from ..util import parse_var_list +from ..util import add_to_time_input from .plot_data_plane_wrapper import PlotDataPlaneWrapper from . import RuntimeFreqWrapper @@ -117,7 +119,7 @@ def create_c_dict(self): extra_args={'remove_quotes': True}) # get stat list to loop over - c_dict['STAT_LIST'] = util.getlist( + c_dict['STAT_LIST'] = getlist( self.config.getstr('config', 'SERIES_ANALYSIS_STAT_LIST', '') @@ -349,11 +351,12 @@ def run_once_per_lead(self, custom): # create input dict and only set 'now' item # create a new dictionary each iteration in case the function # that it is passed into modifies it - input_dict = set_input_dict(loop_time=None, - config=self.config, - use_init=None, - instance=self.instance, - custom=custom) + input_dict = {} + add_to_time_input(input_dict, + clock_time=self.config.getstr('config', + 'CLOCK_TIME'), + instance=self.instance, + custom=custom) input_dict['init'] = '*' input_dict['valid'] = '*' @@ -490,11 +493,13 @@ def find_input_files(self, time_info, data_type): """! Loop over list of input templates and find files for each @param time_info time dictionary to use for string substitution + @param data_type type of data to find, i.e. FCST or OBS @returns Input file list if all files were found, None if not. """ input_files = self.find_data(time_info, return_list=True, - data_type=data_type) + data_type=data_type, + mandatory=False) return input_files def subset_input_files(self, time_info): diff --git a/metplus/wrappers/stat_analysis_wrapper.py b/metplus/wrappers/stat_analysis_wrapper.py index 44fabeb66e..d95bb32305 100755 --- a/metplus/wrappers/stat_analysis_wrapper.py +++ b/metplus/wrappers/stat_analysis_wrapper.py @@ -18,6 +18,7 @@ import datetime import itertools +from ..util import getlist from ..util import met_util as util from ..util import do_string_sub, find_indices_in_config_section from ..util import parse_var_list @@ -184,7 +185,7 @@ def create_c_dict(self): conf_list in all_lists_to_read if conf_list not in self.field_lists] for conf_list in non_field_lists: - c_dict[conf_list] = util.getlist( + c_dict[conf_list] = getlist( self.config.getstr('config', conf_list, '') ) @@ -305,7 +306,7 @@ def read_field_lists_from_config(self, field_dict): self.get_level_list(field_list.split('_')[0]) ) else: - field_dict[field_list] = util.getlist( + field_dict[field_list] = getlist( self.config.getstr('config', field_list, '') @@ -1318,7 +1319,7 @@ def get_level_list(self, data_type): """ level_list = [] - level_input = util.getlist( + level_input = getlist( self.config.getstr('config', f'{data_type}_LEVEL_LIST', '') ) @@ -1495,7 +1496,7 @@ def get_c_dict_list(self): False) ) if run_fourier: - fourier_wave_num_pairs = util.getlist( + fourier_wave_num_pairs = getlist( self.config.getstr('config', 'VAR' + var_info['index'] + '_WAVE_NUM_LIST', '') diff --git a/metplus/wrappers/tc_gen_wrapper.py b/metplus/wrappers/tc_gen_wrapper.py index 91168a0e96..3f6393b841 100755 --- a/metplus/wrappers/tc_gen_wrapper.py +++ b/metplus/wrappers/tc_gen_wrapper.py @@ -16,8 +16,9 @@ from ..util import met_util as util from ..util import time_util -from . import CommandBuilder from ..util import do_string_sub +from ..util import time_generator +from . import CommandBuilder '''!@namespace TCGenWrapper @brief Wraps the TC-Gen tool @@ -262,9 +263,8 @@ def create_c_dict(self): ) self.add_met_config_window('genesis_match_window') - # get INPUT_TIME_DICT values since wrapper only runs - # once (doesn't look over time) - c_dict['INPUT_TIME_DICT'] = self.set_time_dict_for_single_runtime() + # get INPUT_TIME_DICT values since wrapper doesn't loop over time + c_dict['INPUT_TIME_DICT'] = next(time_generator(self.config)) if not c_dict['INPUT_TIME_DICT']: self.isOK = False diff --git a/metplus/wrappers/tc_pairs_wrapper.py b/metplus/wrappers/tc_pairs_wrapper.py index f86e814e13..c3e6a67a5b 100755 --- a/metplus/wrappers/tc_pairs_wrapper.py +++ b/metplus/wrappers/tc_pairs_wrapper.py @@ -20,11 +20,13 @@ import datetime import glob +from ..util import getlist from ..util import time_util from ..util import met_util as util from ..util import do_string_sub from ..util import get_tags from ..util.met_config import add_met_config_dict_list +from ..util import time_generator, log_runtime_banner, add_to_time_input from . import CommandBuilder '''!@namespace TCPairsWrapper @@ -167,13 +169,10 @@ def create_c_dict(self): self.handle_consensus() - # if looping by processes, get the init or valid beg time and run once - c_dict['INPUT_DICT'] = self.get_start_time_input_dict() - - c_dict['INIT_INCLUDE'] = util.getlist( + c_dict['INIT_INCLUDE'] = getlist( self.get_wrapper_or_generic_config('INIT_INCLUDE') ) - c_dict['INIT_EXCLUDE'] = util.getlist( + c_dict['INIT_EXCLUDE'] = getlist( self.get_wrapper_or_generic_config('INIT_EXCLUDE') ) c_dict['VALID_BEG'] = self.get_wrapper_or_generic_config('VALID_BEG') @@ -195,7 +194,7 @@ def create_c_dict(self): ) # get list of models to process - c_dict['MODEL_LIST'] = util.getlist( + c_dict['MODEL_LIST'] = getlist( self.config.getraw('config', 'MODEL', '') ) # if no models are requested, set list to contain a single string @@ -205,7 +204,7 @@ def create_c_dict(self): self._read_storm_info(c_dict) - c_dict['STORM_NAME_LIST'] = util.getlist( + c_dict['STORM_NAME_LIST'] = getlist( self.config.getraw('config', 'TC_PAIRS_STORM_NAME') ) c_dict['DLAND_FILE'] = self.config.getraw('config', @@ -287,13 +286,13 @@ def _read_storm_info(self, c_dict): @param c_dict dictionary to populate with values from config @returns None """ - storm_id_list = util.getlist( + storm_id_list = getlist( self.config.getraw('config', 'TC_PAIRS_STORM_ID', '') ) - cyclone_list = util.getlist( + cyclone_list = getlist( self.config.getraw('config', 'TC_PAIRS_CYCLONE', '') ) - basin_list = util.getlist( + basin_list = getlist( self.config.getraw('config', 'TC_PAIRS_BASIN', '') ) @@ -336,7 +335,13 @@ def run_all_times(self): """! Build up the command to invoke the MET tool tc_pairs. """ # use first run time - input_dict = self.c_dict.get('INPUT_DICT') + input_dict = next(time_generator(self.config)) + if not input_dict: + return self.all_commands + + add_to_time_input(input_dict, + instance=self.instance) + log_runtime_banner(self.config, input_dict, self) # if running in READ_ALL_FILES mode, call tc_pairs once and exit if self.c_dict['READ_ALL_FILES']: diff --git a/metplus/wrappers/tcmpr_plotter_wrapper.py b/metplus/wrappers/tcmpr_plotter_wrapper.py index 371b1a8417..3a3b6fb6e7 100755 --- a/metplus/wrappers/tcmpr_plotter_wrapper.py +++ b/metplus/wrappers/tcmpr_plotter_wrapper.py @@ -6,9 +6,11 @@ import os import shutil +from ..util import getlist from ..util import met_util as util from ..util import time_util from ..util import do_string_sub +from ..util import time_generator from . import CommandBuilder class TCMPRPlotterWrapper(CommandBuilder): @@ -107,7 +109,7 @@ def create_c_dict(self): self.log_error("TCMPR_PLOTTER_CONFIG_FILE must be set") # get time information - input_dict = self.set_time_dict_for_single_runtime() + input_dict = next(time_generator(self.config)) if not input_dict: self.isOK = False c_dict['TIME_INFO'] = time_util.ti_calculate(input_dict) @@ -151,9 +153,7 @@ def read_optional_args(self): elif 'bool' in data_type: value = self.config.getbool('config', config_name, '') elif 'list' in data_type: - value = util.getlist(self.config.getraw('config', - config_name, - '')) + value = getlist(self.config.getraw('config', config_name)) else: self.log_error(f"Invalid type for {name}: {data_type}") @@ -186,12 +186,8 @@ def read_loop_info(self): elif name == 'dep': config_name = f'{config_name}_VARS' - values = util.getlist(self.config.getraw('config', - config_name, - '')) - labels = util.getlist(self.config.getraw('config', - label_config_name, - '')) + values = getlist(self.config.getraw('config', config_name)) + labels = getlist(self.config.getraw('config', label_config_name)) # if labels are not set, use values as labels if not labels: diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_CrossSpectraPlot.conf b/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_CrossSpectraPlot.conf index 08d56d2a5b..670c14b592 100644 --- a/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_CrossSpectraPlot.conf +++ b/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_CrossSpectraPlot.conf @@ -1,43 +1,12 @@ [config] -# time looping - options are INIT, VALID, RETRO, and REALTIME -# If set to INIT or RETRO: -# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set -# If set to VALID or REALTIME: -# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set -LOOP_BY = REALTIME - -# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. -# see www.strftime.org for more information -# %Y%m%d%H expands to YYYYMMDDHH -VALID_TIME_FMT = %Y%m%d%H - -# BLank for this usecase but the parameter still needs to be there -VALID_BEG = - -# BLank for this usecase but the parameter still needs to be there -VALID_END = - -# BLank for this usecase but the parameter still needs to be there -VALID_INCREMENT = - -# List of forecast leads to process for each run time (init or valid) -# In hours if units are not specified -# If unset, defaults to 0 (don't loop through forecast leads) -LEAD_SEQ = - -# Order of loops to process data - Options are times, processes -# Not relevant if only one item is in the PROCESS_LIST -# times = run all wrappers in the PROCESS_LIST for a single run time, then -# increment the run time and run all wrappers again until all times have -# been evaluated. -# processes = run the first wrapper in the PROCESS_LIST for all times -# specified, then repeat for the next item in the PROCESS_LIST until all -# wrappers have been run -LOOP_ORDER = processes - PROCESS_LIST = UserScript +# Note: time looping is not used in this use case +LOOP_BY = REALTIME +VALID_TIME_FMT = %Y +VALID_BEG = 2020 + USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE USER_SCRIPT_COMMAND = {PARM_BASE}/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_CrossSpectraPlot/cross_spectra_plot.py diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_Hovmoeller.conf b/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_Hovmoeller.conf index 1b688230f6..9656a1b0e1 100644 --- a/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_Hovmoeller.conf +++ b/parm/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_Hovmoeller.conf @@ -1,44 +1,11 @@ - [config] -# time looping - options are INIT, VALID, RETRO, and REALTIME -# If set to INIT or RETRO: -# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set -# If set to VALID or REALTIME: -# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set -LOOP_BY = REALTIME - -# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. -# see www.strftime.org for more information -# %Y%m%d%H expands to YYYYMMDDHH -VALID_TIME_FMT = %Y%m%d%H - -# BLank for this usecase but the parameter still needs to be there -VALID_BEG = - -# BLank for this usecase but the parameter still needs to be there -VALID_END = - -# BLank for this usecase but the parameter still needs to be there -VALID_INCREMENT = - -# List of forecast leads to process for each run time (init or valid) -# In hours if units are not specified -# If unset, defaults to 0 (don't loop through forecast leads) -LEAD_SEQ = - -# Order of loops to process data - Options are times, processes -# Not relevant if only one item is in the PROCESS_LIST -# times = run all wrappers in the PROCESS_LIST for a single run time, then -# increment the run time and run all wrappers again until all times have -# been evaluated. -# processes = run the first wrapper in the PROCESS_LIST for all times -# specified, then repeat for the next item in the PROCESS_LIST until all -# wrappers have been run -LOOP_ORDER = processes - PROCESS_LIST = UserScript +LOOP_BY = REALTIME +VALID_TIME_FMT = %Y +VALID_BEG = 2014 + USER_SCRIPT_RUNTIME_FREQ = RUN_ONCE USER_SCRIPT_COMMAND = {PARM_BASE}/use_cases/model_applications/s2s/UserScript_obsPrecip_obsOnly_Hovmoeller/hovmoeller_diagram.py From 46658c6b2436d4200bda837184b8cbfca975699f Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Mon, 27 Dec 2021 15:41:47 -0700 Subject: [PATCH 22/43] Feature 896 more met config (#1322) --- docs/Contributors_Guide/basic_components.rst | 8 +- docs/Users_Guide/glossary.rst | 164 +++++++++- docs/Users_Guide/wrappers.rst | 229 ++++++++++++-- .../grid_stat/test_grid_stat_wrapper.py | 16 + .../pytests/mode/test_mode_wrapper.py | 5 +- .../pytests/pb2nc/test_pb2nc_wrapper.py | 4 + .../point_stat/test_point_stat_wrapper.py | 38 +++ .../series_analysis/test_series_analysis.py | 70 ++++- .../pytests/tc_pairs/test_tc_pairs_wrapper.py | 7 + metplus/util/diff_util.py | 2 +- metplus/util/doc_util.py | 280 ++++++++++++------ metplus/util/met_config.py | 5 +- metplus/wrappers/command_builder.py | 21 +- metplus/wrappers/compare_gridded_wrapper.py | 3 +- metplus/wrappers/grid_stat_wrapper.py | 16 + metplus/wrappers/mode_wrapper.py | 9 + metplus/wrappers/pb2nc_wrapper.py | 10 + metplus/wrappers/point_stat_wrapper.py | 23 ++ metplus/wrappers/series_analysis_wrapper.py | 56 +++- metplus/wrappers/tc_pairs_wrapper.py | 10 + parm/met_config/GridStatConfig_wrapped | 12 +- parm/met_config/MODEConfig_wrapped | 7 +- parm/met_config/PB2NCConfig_wrapped | 21 +- parm/met_config/PointStatConfig_wrapped | 22 +- parm/met_config/SeriesAnalysisConfig_wrapped | 16 +- parm/met_config/TCPairsConfig_wrapped | 7 +- .../met_tool_wrapper/GridStat/GridStat.conf | 6 + .../use_cases/met_tool_wrapper/MODE/MODE.conf | 5 +- .../met_tool_wrapper/PB2NC/PB2NC.conf | 85 +----- .../met_tool_wrapper/PointStat/PointStat.conf | 191 ++++-------- .../SeriesAnalysis/SeriesAnalysis.conf | 140 +++++---- .../TCPairs/TCPairs_extra_tropical.conf | 132 +++------ .../TCPairs/TCPairs_tropical.conf | 115 +++---- 33 files changed, 1086 insertions(+), 649 deletions(-) diff --git a/docs/Contributors_Guide/basic_components.rst b/docs/Contributors_Guide/basic_components.rst index 86d3d57323..7de9af11d8 100644 --- a/docs/Contributors_Guide/basic_components.rst +++ b/docs/Contributors_Guide/basic_components.rst @@ -302,12 +302,12 @@ data type, extra info, children, and nicknames. * extra: Additional info as a comma separated string (see extra_args above) * children: Dictionary defining a nested dictionary where the key is the name of the sub-directory and the value is the item info (see items above) -* nicknames: List of METplus variable names (with app name excluded) to also +* nicknames: List of METplus variable names to also search and use if it is set. For example, the GridStat variable mask.poly is set by the METplus config variable GRID_STAT_MASK_POLY. However, in older versions of the METplus wrappers, the variable used was GRID_STAT_VERIFICATION_MASK_TEMPLATE. To preserve support for this name, the - nickname can be set to ['VERIFICATION_MASK_TEMPLATE'] and the old variable + nickname can be set to [f'{self.app_name.upper()}_VERIFICATION_MASK_TEMPLATE'] and the old variable will be checked if GRID_STAT_MASK_POLY is not set. Values must be set to None to preserve the order. @@ -320,7 +320,7 @@ CompareGriddedWrapper and is used by GridStat, PointStat, and EnsembleStat:: def handle_climo_cdf_dict(self): self.add_met_config_dict('climo_cdf', { - 'cdf_bins': ('float', None, None, ['CLIMO_CDF_BINS']), + 'cdf_bins': ('float', None, None, [f'{self.app_name.upper()}_CLIMO_CDF_BINS']), 'center_bins': 'bool', 'write_bins': 'bool', }) @@ -329,7 +329,7 @@ This function handles setting the climo_cdf dictionary. The METplus config variable that fits the format {APP_NAME}_{DICTIONARY_NAME}_{VARIABLE_NAME}, i.e. GRID_STAT_CLIMO_CDF_CDF_BINS for GridStat's climo_cdf.cdf_bins, is quieried first. However, this default name is a little redundant, so adding -the nickname 'CLIMO_CDF_BINS' allows the user to set the variable +the nickname 'GRID_STAT_CLIMO_CDF_BINS' allows the user to set the variable GRID_STAT_CLIMO_CDF_BINS instead. There are many MET config dictionaries that only contain beg and end to define diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index ca701205ce..5183c899be 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -3431,9 +3431,7 @@ METplus Configuration Glossary | *Used by:* PB2NC POINT_STAT_STATION_ID - Specify the ID of a specific station to use with the MET point_stat tool. - - | *Used by:* PointStat + .. warning:: **DEPRECATED:** Please use :term:`POINT_STAT_MASK_SID` instead. POINT_STAT_VERIFICATION_MASK_TEMPLATE Template used to specify the verification mask filename for the MET tool point_stat. Now supports a list of filenames. @@ -3734,17 +3732,13 @@ METplus Configuration Glossary .. warning:: **DEPRECATED:** Please use :term:`MAKE_PLOTS_INPUT_DIR` instead. SERIES_ANALYSIS_STAT_LIST - Specify a list of statistics to be computed by the MET series_analysis tool. Sets the 'cnt' value in the output_stats dictionary in the MET SeriesAnalysis config file - - | *Used by:* SeriesAnalysis + .. warning:: **DEPRECATED:** Please use :term:`SERIES_ANALYSIS_OUTPUT_STATS_CNT` instead. SERIES_ANALYSIS_CTS_LIST - Specify a list of contingency table statistics to be computed by the MET series_analysis tool. Sets the 'cts' value in the output_stats dictionary in the MET SeriesAnalysis config file - - | *Used by:* SeriesAnalysis + .. warning:: **DEPRECATED:** Please use :term:`SERIES_ANALYSIS_OUTPUT_STATS_CTS` instead. STAT_LIST - .. warning:: **DEPRECATED:** Please use :term:`SERIES_ANALYSIS_STAT_LIST` instead. + .. warning:: **DEPRECATED:** Please use :term:`SERIES_ANALYSIS_OUTPUT_STATS_CNT` instead. STORM_ID .. warning:: **DEPRECATED:** Please use :term:`TC_PAIRS_STORM_ID` or :term:`TC_STAT_STORM_ID`. @@ -6066,6 +6060,11 @@ METplus Configuration Glossary | *Used by:* PointStat + POINT_STAT_MASK_LLPNT + Specify the value for 'mask.llpnt' in the MET configuration file for PointStat. + + | *Used by:* PointStat + MODE_GRID_RES Set the grid_res entry in the MODE MET config file. @@ -8530,6 +8529,151 @@ METplus Configuration Glossary | *Used by:* EnsembleStat + GRID_STAT_FOURIER_WAVE_1D_BEG + Specify the value for 'fourier.wave_1d_beg' in the MET configuration file for GridStat. + + | *Used by:* GridStat + + GRID_STAT_FOURIER_WAVE_1D_END + Specify the value for 'fourier.wave_1d_end' in the MET configuration file for GridStat. + + | *Used by:* GridStat + + PB2NC_OBS_BUFR_MAP + Specify the value for 'obs_bufr_map' in the MET configuration file for PB2NC. + + | *Used by:* PB2NC + + PB2NC_OBS_PREPBUFR_MAP + Specify the value for 'obs_prepbufr_map' in the MET configuration file for PB2NC. + + | *Used by:* PB2NC + + POINT_STAT_HIRA_FLAG + Specify the value for 'hira.flag' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_HIRA_WIDTH + Specify the value for 'hira.width' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_HIRA_VLD_THRESH + Specify the value for 'hira.vld_thresh' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_HIRA_COV_THRESH + Specify the value for 'hira.cov_thresh' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_HIRA_SHAPE + Specify the value for 'hira.shape' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_HIRA_PROB_CAT_THRESH + Specify the value for 'hira.prob_cat_thresh' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + POINT_STAT_MESSAGE_TYPE_GROUP_MAP + Specify the value for 'message_type_group_map' in the MET configuration file for PointStat. + + | *Used by:* PointStat + + TC_PAIRS_CHECK_DUP + Specify the value for 'check_dup' in the MET configuration file for TCPairs. + + | *Used by:* TCPairs + + TC_PAIRS_INTERP12 + Specify the value for 'interp12' in the MET configuration file for TCPairs. + + | *Used by:* TCPairs + + SERIES_ANALYSIS_OUTPUT_STATS_FHO + Specify the value for 'output_stats.fho' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_CTC + Specify the value for 'output_stats.ctc' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_CTS + Specify the value for 'output_stats.cts' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_MCTC + Specify the value for 'output_stats.mctc' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_MCTS + Specify the value for 'output_stats.mcts' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_CNT + Specify the value for 'output_stats.cnt' in the MET configuration file for SeriesAnalysis. Also used to generate plots for each value in the list. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_SL1L2 + Specify the value for 'output_stats.sl1l2' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_SAL1L2 + Specify the value for 'output_stats.sal1l2' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_PCT + Specify the value for 'output_stats.pct' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_PSTD + Specify the value for 'output_stats.pstd' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_PJC + Specify the value for 'output_stats.pjc' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_OUTPUT_STATS_PRC + Specify the value for 'output_stats.prc' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + MODE_PS_PLOT_FLAG + Specify the value for 'ps_plot_flag' in the MET configuration file for MODE. + + | *Used by:* MODE + + MODE_CT_STATS_FLAG + Specify the value for 'ct_stats_flag' in the MET configuration file for MODE. + + | *Used by:* MODE + + GRID_STAT_CENSOR_THRESH + Specify the value for 'censor_thresh' in the MET configuration file for GridStat. + + | *Used by:* GridStat + + GRID_STAT_CENSOR_VAL + Specify the value for 'censor_val' in the MET configuration file for GridStat. + + | *Used by:* GridStat + INIT_LIST List of initialization times to process. This variable is used when intervals between run times are irregular. diff --git a/docs/Users_Guide/wrappers.rst b/docs/Users_Guide/wrappers.rst index 40df66983e..9cd7af499e 100644 --- a/docs/Users_Guide/wrappers.rst +++ b/docs/Users_Guide/wrappers.rst @@ -2800,6 +2800,10 @@ METplus Configuration | :term:`GRID_STAT_DISTANCE_MAP_FOM_ALPHA` | :term:`GRID_STAT_DISTANCE_MAP_ZHU_WEIGHT` | :term:`GRID_STAT_DISTANCE_MAP_BETA_VALUE_N` +| :term:`GRID_STAT_FOURIER_WAVE_1D_BEG` +| :term:`GRID_STAT_FOURIER_WAVE_1D_END` +| :term:`GRID_STAT_CENSOR_THRESH` +| :term:`GRID_STAT_CENSOR_VAL` | :term:`GRID_STAT_MASK_GRID` (optional) | :term:`GRID_STAT_MASK_POLY` (optional) | :term:`GRID_STAT_MET_CONFIG_OVERRIDES` @@ -3266,6 +3270,42 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`GRID_STAT_DISTANCE_MAP_BETA_VALUE_N` - distance_map.beta_value(n) +**${METPLUS_FOURIER_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`GRID_STAT_FOURIER_WAVE_1D_BEG` + - fourier.wave_1d_beg + * - :term:`GRID_STAT_FOURIER_WAVE_1D_END` + - fourier.wave_1d_end + +**${METPLUS_CENSOR_THRESH}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`GRID_STAT_CENSOR_THRESH` + - censor_thresh + +**${METPLUS_CENSOR_VAL}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`GRID_STAT_CENSOR_VAL` + - censor_val + + .. _ioda2nc_wrapper: IODA2NC @@ -3926,6 +3966,8 @@ METplus Configuration | :term:`MODE_INTEREST_FUNCTION_CENTROID_DIST` | :term:`MODE_INTEREST_FUNCTION_BOUNDARY_DIST` | :term:`MODE_INTEREST_FUNCTION_CONVEX_HULL_DIST` +| :term:`MODE_PS_PLOT_FLAG` +| :term:`MODE_CT_STATS_FLAG` | :term:`FCST_MODE_VAR_NAME` (optional) | :term:`FCST_MODE_VAR_LEVELS` (optional) | :term:`FCST_MODE_VAR_THRESH` (optional) @@ -4440,7 +4482,27 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`MODE_TOTAL_INTEREST_THRESH` - total_interest_thresh +**${METPLUS_PS_PLOT_FLAG}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`MODE_PS_PLOT_FLAG` + - ps_plot_flag + +**${METPLUS_CT_STATS_FLAG}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + * - METplus Config(s) + - MET Config File + * - :term:`MODE_CT_STATS_FLAG` + - ct_stats_flag .. _mtd_wrapper: @@ -4750,6 +4812,8 @@ METplus Configuration | :term:`PB2NC_LEVEL_RANGE_END` | :term:`PB2NC_LEVEL_CATEGORY` | :term:`PB2NC_QUALITY_MARK_THRESH` +| :term:`PB2NC_OBS_BUFR_MAP` +| :term:`PB2NC_OBS_PREPBUFR_MAP` .. warning:: **DEPRECATED:** @@ -4938,6 +5002,27 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`PB2NC_QUALITY_MARK_THRESH` - quality_mark_thresh +**${METPLUS_OBS_BUFR_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`PB2NC_OBS_BUFR_MAP` + - obs_bufr_map + +**${METPLUS_OBS_PREPBUFR_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`PB2NC_OBS_PREPBUFR_MAP` + - obs_prepbufr_map .. _pcp_combine_wrapper: @@ -5136,9 +5221,10 @@ Configuration | :term:`POINT_STAT_REGRID_WIDTH` | :term:`POINT_STAT_REGRID_VLD_THRESH` | :term:`POINT_STAT_REGRID_SHAPE` -| :term:`POINT_STAT_GRID` -| :term:`POINT_STAT_POLY` -| :term:`POINT_STAT_STATION_ID` +| :term:`POINT_STAT_MASK_GRID` +| :term:`POINT_STAT_MASK_POLY` +| :term:`POINT_STAT_MASK_SID` +| :term:`POINT_STAT_MASK_LLPNT` | :term:`POINT_STAT_MESSAGE_TYPE` | :term:`POINT_STAT_CUSTOM_LOOP_LIST` | :term:`POINT_STAT_SKIP_IF_OUTPUT_EXISTS` @@ -5194,6 +5280,13 @@ Configuration | :term:`POINT_STAT_CLIMO_STDEV_DAY_INTERVAL` | :term:`POINT_STAT_CLIMO_STDEV_HOUR_INTERVAL` | :term:`POINT_STAT_HSS_EC_VALUE` +| :term:`POINT_STAT_HIRA_FLAG` +| :term:`POINT_STAT_HIRA_WIDTH` +| :term:`POINT_STAT_HIRA_VLD_THRESH` +| :term:`POINT_STAT_HIRA_COV_THRESH` +| :term:`POINT_STAT_HIRA_SHAPE` +| :term:`POINT_STAT_HIRA_PROB_CAT_THRESH` +| :term:`POINT_STAT_MESSAGE_TYPE_GROUP_MAP` | :term:`FCST_POINT_STAT_WINDOW_BEGIN` (optional) | :term:`FCST_POINT_STAT_WINDOW_END` (optional) | :term:`OBS_POINT_STAT_WINDOW_BEGIN` (optional) @@ -5453,6 +5546,18 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`POINT_STAT_MASK_SID` - mask.sid +**${METPLUS_MASK_LLPNT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`POINT_STAT_MASK_LLPNT` + - mask.llpnt + + **${METPLUS_OUTPUT_PREFIX}** .. list-table:: @@ -5589,6 +5694,37 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`POINT_STAT_HSS_EC_VALUE` - hss_ec_value +**${METPLUS_HIRA_DICT}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`POINT_STAT_HIRA_FLAG` + - hira.flag + * - :term:`POINT_STAT_HIRA_WIDTH` + - hira.width + * - :term:`POINT_STAT_HIRA_VLD_THRESH` + - hira.vld_thresh + * - :term:`POINT_STAT_HIRA_COV_THRESH` + - hira.cov_thresh + * - :term:`POINT_STAT_HIRA_SHAPE` + - hira.shape + * - :term:`POINT_STAT_HIRA_PROB_CAT_THRESH` + - hira.prob_cat_thresh + +**${METPLUS_MESSAGE_TYPE_GROUP_MAP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`POINT_STAT_MESSAGE_TYPE_GROUP_MAP` + - message_type_group_map .. _py_embed_ingest_wrapper: @@ -5746,6 +5882,18 @@ METplus Configuration | :term:`SERIES_ANALYSIS_CLIMO_STDEV_DAY_INTERVAL` | :term:`SERIES_ANALYSIS_CLIMO_STDEV_HOUR_INTERVAL` | :term:`SERIES_ANALYSIS_HSS_EC_VALUE` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_FHO` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_CTC` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_CTS` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_MCTC` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_MCTS` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_CNT` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_SL1L2` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_SAL1L2` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_PCT` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_PSTD` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_PJC` +| :term:`SERIES_ANALYSIS_OUTPUT_STATS_PRC` | .. warning:: **DEPRECATED:** @@ -5981,18 +6129,7 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`SERIES_ANALYSIS_VLD_THRESH` - vld_thresh -**${METPLUS_CTS_LIST}** - -.. list-table:: - :widths: 5 5 - :header-rows: 0 - - * - METplus Config(s) - - MET Config File - * - :term:`SERIES_ANALYSIS_CTS_LIST` - - output_stats.cts - -**${METPLUS_STAT_LIST}** +**${METPLUS_MET_CONFIG_OVERRIDES}** .. list-table:: :widths: 5 5 @@ -6000,10 +6137,10 @@ see :ref:`How METplus controls MET config file settings`. * - METplus Config(s) - MET Config File - * - :term:`SERIES_ANALYSIS_STAT_LIST` - - output_stats.cnt + * - :term:`SERIES_ANALYSIS_MET_CONFIG_OVERRIDES` + - n/a -**${METPLUS_MET_CONFIG_OVERRIDES}** +**${METPLUS_HSS_EC_VALUE}** .. list-table:: :widths: 5 5 @@ -6011,10 +6148,10 @@ see :ref:`How METplus controls MET config file settings`. * - METplus Config(s) - MET Config File - * - :term:`SERIES_ANALYSIS_MET_CONFIG_OVERRIDES` - - n/a + * - :term:`SERIES_ANALYSIS_HSS_EC_VALUE` + - hss_ec_value -**${METPLUS_HSS_EC_VALUE}** +**${METPLUS_OUTPUT_STATS_DICT}** .. list-table:: :widths: 5 5 @@ -6022,8 +6159,30 @@ see :ref:`How METplus controls MET config file settings`. * - METplus Config(s) - MET Config File - * - :term:`SERIES_ANALYSIS_HSS_EC_VALUE` - - hss_ec_value + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_FHO` + - output_stats.fho + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_CTC` + - output_stats.ctc + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_CTS` + - output_stats.cts + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_MCTC` + - output_stats.mctc + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_MCTS` + - output_stats.mcts + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_CNT` + - output_stats.cnt + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_SL1L2` + - output_stats.sl1l2 + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_SAL1L2` + - output_stats.sal1l2 + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_PCT` + - output_stats.pct + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_PSTD` + - output_stats.pstd + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_PJC` + - output_stats.pjc + * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_PRC` + - output_stats.prc SeriesByInit @@ -7329,6 +7488,8 @@ METplus Configuration | :term:`TC_PAIRS_CONSENSUS_MIN_REQ` | :term:`TC_PAIRS_SKIP_LEAD_SEQ` | :term:`TC_PAIRS_RUN_ONCE` +| :term:`TC_PAIRS_CHECK_DUP` +| :term:`TC_PAIRS_INTERP12` | .. warning:: **DEPRECATED:** @@ -7578,6 +7739,28 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`TC_PAIRS_CONSENSUS_MIN_REQ` - consensus.min_req +**${METPLUS_CHECK_DUP}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`TC_PAIRS_CHECK_DUP` + - check_dup + +**${METPLUS_INTERP12}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`TC_PAIRS_INTERP12` + - interp12 + .. _tcrmw_wrapper: TCRMW diff --git a/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py b/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py index b38bee453a..80c7393c83 100644 --- a/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py +++ b/internal_tests/pytests/grid_stat/test_grid_stat_wrapper.py @@ -567,6 +567,21 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, 'baddeley_max_dist = 2.3;' 'fom_alpha = 4.5;zhu_weight = 0.5;' 'beta_value(n) = n * n / 3.0;}')}), + ({'GRID_STAT_FOURIER_WAVE_1D_BEG': '0,4,10', }, + {'METPLUS_FOURIER_DICT': 'fourier = {wave_1d_beg = [0, 4, 10];}'}), + + ({'GRID_STAT_FOURIER_WAVE_1D_END': '3,9,20', }, + {'METPLUS_FOURIER_DICT': 'fourier = {wave_1d_end = [3, 9, 20];}'}), + + ({'GRID_STAT_FOURIER_WAVE_1D_BEG': '0,4,10', + 'GRID_STAT_FOURIER_WAVE_1D_END': '3,9,20',}, + {'METPLUS_FOURIER_DICT': ('fourier = {wave_1d_beg = [0, 4, 10];' + 'wave_1d_end = [3, 9, 20];}')}), + ({'GRID_STAT_CENSOR_THRESH': '>12000,<5000', }, + {'METPLUS_CENSOR_THRESH': 'censor_thresh = [>12000, <5000];'}), + + ({'GRID_STAT_CENSOR_VAL': '12000, 5000', }, + {'METPLUS_CENSOR_VAL': 'censor_val = [12000, 5000];'}), ] ) @@ -616,6 +631,7 @@ def test_grid_stat_single_field(metplus_config, config_overrides, item.startswith(env_var_key)), None) assert(match is not None) actual_value = match.split('=', 1)[1] + print(f"ENV VAR: {env_var_key}") if env_var_key == 'METPLUS_FCST_FIELD': assert(actual_value == fcst_fmt) elif env_var_key == 'METPLUS_OBS_FIELD': diff --git a/internal_tests/pytests/mode/test_mode_wrapper.py b/internal_tests/pytests/mode/test_mode_wrapper.py index 8c4144fd68..4ee11d41ee 100644 --- a/internal_tests/pytests/mode/test_mode_wrapper.py +++ b/internal_tests/pytests/mode/test_mode_wrapper.py @@ -302,7 +302,10 @@ def set_minimum_config_settings(config): '(0.0, 2.0) ' '200.0/grid_res, 1.0)' ');')}), - + ({'MODE_PS_PLOT_FLAG': 'True', }, + {'METPLUS_PS_PLOT_FLAG': 'ps_plot_flag = TRUE;'}), + ({'MODE_CT_STATS_FLAG': 'True', }, + {'METPLUS_CT_STATS_FLAG': 'ct_stats_flag = TRUE;'}), ] ) def test_mode_single_field(metplus_config, config_overrides, diff --git a/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py b/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py index a1c202c335..97b920b957 100644 --- a/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py +++ b/internal_tests/pytests/pb2nc/test_pb2nc_wrapper.py @@ -279,6 +279,10 @@ def test_find_input_files(metplus_config, offsets, offset_to_find): 'type = ["min", "max", "range"];' 'vld_freq = 1;' 'vld_thresh = 0.1;}')}), + ({'PB2NC_OBS_BUFR_MAP': '{key="POB"; val="PRES"; },{key="QOB"; val="SPFH";}', }, + {'METPLUS_OBS_BUFR_MAP': 'obs_bufr_map = [{key="POB"; val="PRES"; }, {key="QOB"; val="SPFH";}];'}), + ({'PB2NC_OBS_PREPBUFR_MAP': '{key="POB"; val="PRES"; },{key="QOB"; val="SPFH";}', }, + {'METPLUS_OBS_PREPBUFR_MAP': 'obs_prepbufr_map = [{key="POB"; val="PRES"; }, {key="QOB"; val="SPFH";}];'}), ] ) diff --git a/internal_tests/pytests/point_stat/test_point_stat_wrapper.py b/internal_tests/pytests/point_stat/test_point_stat_wrapper.py index f706e702d1..118a6f7dcc 100755 --- a/internal_tests/pytests/point_stat/test_point_stat_wrapper.py +++ b/internal_tests/pytests/point_stat/test_point_stat_wrapper.py @@ -405,6 +405,44 @@ def test_met_dictionary_in_var_options(metplus_config): 'CLIMO_STDEV_FILE': '"/some/climo_stdev/file.txt"'}), ({'POINT_STAT_HSS_EC_VALUE': '0.5', }, {'METPLUS_HSS_EC_VALUE': 'hss_ec_value = 0.5;'}), + ({'POINT_STAT_MASK_LLPNT': ('{ name = "LAT30TO40"; lat_thresh = >=30&&<=40; lon_thresh = NA; },' + '{ name = "BOX"; lat_thresh = >=20&&<=40; lon_thresh = >=-110&&<=-90; }')}, + {'METPLUS_MASK_LLPNT': 'llpnt = [{ name = "LAT30TO40"; lat_thresh = >=30&&<=40; lon_thresh = NA; }, { name = "BOX"; lat_thresh = >=20&&<=40; lon_thresh = >=-110&&<=-90; }];'}), + + ({'POINT_STAT_HIRA_FLAG': 'False', }, + {'METPLUS_HIRA_DICT': 'hira = {flag = FALSE;}'}), + + ({'POINT_STAT_HIRA_WIDTH': '2,3,4,5', }, + {'METPLUS_HIRA_DICT': 'hira = {width = [2, 3, 4, 5];}'}), + + ({'POINT_STAT_HIRA_VLD_THRESH': '1.0', }, + {'METPLUS_HIRA_DICT': 'hira = {vld_thresh = 1.0;}'}), + + ({'POINT_STAT_HIRA_COV_THRESH': '==0.25, ==0.5', }, + {'METPLUS_HIRA_DICT': 'hira = {cov_thresh = [==0.25, ==0.5];}'}), + + ({'POINT_STAT_HIRA_SHAPE': 'square', }, + {'METPLUS_HIRA_DICT': 'hira = {shape = SQUARE;}'}), + + ({'POINT_STAT_HIRA_PROB_CAT_THRESH': '>1,<=2', }, + {'METPLUS_HIRA_DICT': 'hira = {prob_cat_thresh = [>1, <=2];}'}), + + ({ + 'POINT_STAT_HIRA_FLAG': 'False', + 'POINT_STAT_HIRA_WIDTH': '2,3,4,5', + 'POINT_STAT_HIRA_VLD_THRESH': '1.0', + 'POINT_STAT_HIRA_COV_THRESH': '==0.25, ==0.5', + 'POINT_STAT_HIRA_SHAPE': 'square', + 'POINT_STAT_HIRA_PROB_CAT_THRESH': '>1,<=2', + }, + { + 'METPLUS_HIRA_DICT': ('hira = {flag = FALSE;width = [2, 3, 4, 5];' + 'vld_thresh = 1.0;' + 'cov_thresh = [==0.25, ==0.5];' + 'shape = SQUARE;' + 'prob_cat_thresh = [>1, <=2];}')}), + ({'POINT_STAT_MESSAGE_TYPE_GROUP_MAP': '{ key = "SURFACE"; val = "ADPSFC,SFCSHP,MSONET";},{ key = "ANYAIR"; val = "AIRCAR,AIRCFT";}', }, + {'METPLUS_MESSAGE_TYPE_GROUP_MAP': 'message_type_group_map = [{ key = "SURFACE"; val = "ADPSFC, SFCSHP, MSONET";}, { key = "ANYAIR"; val = "AIRCAR, AIRCFT";}];'}), ] ) diff --git a/internal_tests/pytests/series_analysis/test_series_analysis.py b/internal_tests/pytests/series_analysis/test_series_analysis.py index 1e99c93e63..5d21eb0b39 100644 --- a/internal_tests/pytests/series_analysis/test_series_analysis.py +++ b/internal_tests/pytests/series_analysis/test_series_analysis.py @@ -25,7 +25,7 @@ run_times = ['2005080700',] stat_list = 'TOTAL,RMSE,FBAR,OBAR' stat_list_quotes = '", "'.join(stat_list.split(',')) -stat_list_fmt = f'cnt = ["{stat_list_quotes}"];' +stat_list_fmt = f'output_stats = {{cnt = ["{stat_list_quotes}"];}}' def get_input_dirs(config): fake_data_dir = os.path.join(config.getdir('METPLUS_BASE'), @@ -206,6 +206,71 @@ def set_minimum_config_settings(config): 'CLIMO_STDEV_FILE': '"/some/climo_stdev/file.txt"'}), ({'SERIES_ANALYSIS_HSS_EC_VALUE': '0.5', }, {'METPLUS_HSS_EC_VALUE': 'hss_ec_value = 0.5;'}), + # output_stats + ({'SERIES_ANALYSIS_OUTPUT_STATS_FHO': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {fho = ["RMSE", "FBAR", "OBAR"];cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_CTC': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {ctc = ["RMSE", "FBAR", "OBAR"];cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_CTS': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cts = ["RMSE", "FBAR", "OBAR"];cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_MCTC': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {mctc = ["RMSE", "FBAR", "OBAR"];cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_MCTS': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {mcts = ["RMSE", "FBAR", "OBAR"];cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_CNT': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_SL1L2': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];sl1l2 = ["RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_SAL1L2': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];sal1l2 = ["RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_PCT': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];pct = ["RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_PSTD': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];pstd = ["RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_PJC': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];pjc = ["RMSE", "FBAR", "OBAR"];}'}), + + ({'SERIES_ANALYSIS_OUTPUT_STATS_PRC': 'RMSE,FBAR,OBAR', }, + {'METPLUS_OUTPUT_STATS_DICT': 'output_stats = {cnt = ["TOTAL", "RMSE", "FBAR", "OBAR"];prc = ["RMSE", "FBAR", "OBAR"];}'}), + + ({ + 'SERIES_ANALYSIS_OUTPUT_STATS_FHO': 'RMSE1,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_CTC': 'RMSE2,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_CTS': 'RMSE3,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_MCTC': 'RMSE4,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_MCTS': 'RMSE5,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_CNT': 'RMSE6,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_SL1L2': 'RMSE7,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_SAL1L2': 'RMSE8,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_PCT': 'RMSE9,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_PSTD': 'RMSE10,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_PJC': 'RMSE11,FBAR,OBAR', + 'SERIES_ANALYSIS_OUTPUT_STATS_PRC': 'RMSE12,FBAR,OBAR', + }, + {'METPLUS_OUTPUT_STATS_DICT': ('output_stats = {' + 'fho = ["RMSE1", "FBAR", "OBAR"];' + 'ctc = ["RMSE2", "FBAR", "OBAR"];' + 'cts = ["RMSE3", "FBAR", "OBAR"];' + 'mctc = ["RMSE4", "FBAR", "OBAR"];' + 'mcts = ["RMSE5", "FBAR", "OBAR"];' + 'cnt = ["RMSE6", "FBAR", "OBAR"];' + 'sl1l2 = ["RMSE7", "FBAR", "OBAR"];' + 'sal1l2 = ["RMSE8", "FBAR", "OBAR"];' + 'pct = ["RMSE9", "FBAR", "OBAR"];' + 'pstd = ["RMSE10", "FBAR", "OBAR"];' + 'pjc = ["RMSE11", "FBAR", "OBAR"];' + 'prc = ["RMSE12", "FBAR", "OBAR"];}')}), + ] ) def test_series_analysis_single_field(metplus_config, config_overrides, @@ -248,11 +313,12 @@ def test_series_analysis_single_field(metplus_config, config_overrides, item.startswith(env_var_key)), None) assert(match is not None) actual_value = match.split('=', 1)[1] + print(f"ENV VAR: {env_var_key}") if env_var_key == 'METPLUS_FCST_FIELD': assert(actual_value == fcst_fmt) elif env_var_key == 'METPLUS_OBS_FIELD': assert (actual_value == obs_fmt) - elif env_var_key == 'METPLUS_STAT_LIST': + elif env_var_key == 'METPLUS_OUTPUT_STATS_DICT' and 'METPLUS_OUTPUT_STATS_DICT' not in env_var_values: assert (actual_value == stat_list_fmt) else: assert(env_var_values.get(env_var_key, '') == actual_value) diff --git a/internal_tests/pytests/tc_pairs/test_tc_pairs_wrapper.py b/internal_tests/pytests/tc_pairs/test_tc_pairs_wrapper.py index 5696553914..a0845be227 100644 --- a/internal_tests/pytests/tc_pairs/test_tc_pairs_wrapper.py +++ b/internal_tests/pytests/tc_pairs/test_tc_pairs_wrapper.py @@ -361,6 +361,13 @@ def test_tc_pairs_storm_id_lists(metplus_config, config_overrides, # 17: write_valid ({'TC_PAIRS_WRITE_VALID': '20141031_14'}, {'METPLUS_WRITE_VALID': 'write_valid = ["20141031_14"];'}), + # 18: check_dup + ({'TC_PAIRS_CHECK_DUP': 'False', }, + {'METPLUS_CHECK_DUP': 'check_dup = FALSE;'}), + # 19: interp12 + ({'TC_PAIRS_INTERP12': 'replace', }, + {'METPLUS_INTERP12': 'interp12 = REPLACE;'}), + ] ) def test_tc_pairs_loop_order_processes(metplus_config, config_overrides, diff --git a/metplus/util/diff_util.py b/metplus/util/diff_util.py index e894bbda04..8c59539052 100644 --- a/metplus/util/diff_util.py +++ b/metplus/util/diff_util.py @@ -445,7 +445,7 @@ def nc_is_equal(file_a, file_b, fields=None, debug=False): if any(var_a[:].flatten() != var_b[:].flatten()): print(f"ERROR: Field ({field}) values (non-numeric) " "differ\n" - f" File_A: {var_a}\n File_B: {var_b}") + f" File_A: {var_a[:]}\n File_B: {var_b[:]}") is_equal = False except: print("ERROR: Couldn't diff NetCDF files, need to update diff method") diff --git a/metplus/util/doc_util.py b/metplus/util/doc_util.py index 5bf834d86f..9338a69369 100755 --- a/metplus/util/doc_util.py +++ b/metplus/util/doc_util.py @@ -60,7 +60,7 @@ def get_wrapper_name(process_name): return None -def print_doc_text(tool_name, met_var, dict_items): +def print_doc_text(tool_name, input_dict): """! Format documentation for adding support for a new MET config variable through METplus wrappers. @@ -70,91 +70,181 @@ def print_doc_text(tool_name, met_var, dict_items): is a dictionary """ wrapper_caps = tool_name.upper() - met_var_caps = met_var.upper() - env_var_name = f'METPLUS_{met_var_caps}' - wrapper_camel = get_wrapper_name(wrapper_caps) - metplus_var = f'{wrapper_caps}_{met_var_caps}' - - metplus_config_names = [] - met_config_values = [] - if not dict_items: - metplus_config_names.append(metplus_var) - met_config_values.append(met_var) - else: - env_var_name = f'{env_var_name}_DICT' - for item_name in dict_items: - item_name_caps = item_name.upper() - metplus_config_name = f'{metplus_var}_{item_name_caps}' - - metplus_config_names.append(metplus_config_name) - met_config_values.append(f"{met_var}.{item_name}") - - print('WARNING: Guidance output from this script may differ slightly ' + # get info for each variable and store it in a dictionary + met_vars = [] + for var_name, dict_list in input_dict.items(): + metplus_var = f'{wrapper_caps}_{var_name.upper()}' + env_var_name = f'METPLUS_{var_name.upper()}' + met_var = {'name': var_name, 'dict_items': dict_list, + 'metplus_config_names': [], 'met_config_names': []} + if not dict_list: + met_var['env_var_name'] = env_var_name + met_var['metplus_config_names'].append(metplus_var) + met_var['met_config_names'].append(var_name) + else: + met_var['env_var_name'] = f'{env_var_name}_DICT' + for item_name in dict_list: + metplus_config = f'{metplus_var}_{item_name.upper()}' + met_config = f"{var_name}.{item_name}" + met_var['metplus_config_names'].append(metplus_config) + met_var['met_config_names'].append(met_config) + + met_vars.append(met_var) + + print(f"\nWrapper: {wrapper_camel}\n") + for index, var in enumerate(met_vars, 1): + print(f"MET Variable {index}: {var['name']}") + if var['dict_items']: + print(f" Dictionary Items: {', '.join(var['dict_items'])}") + print() + + print('\nWARNING: Guidance output from this script may differ slightly ' 'from the actual steps to take. It is intended to assist the process.' ' The text that is generated should be reviewed for accuracy before ' 'adding to codebase.') - print(f"\nWrapper: {wrapper_camel}") - print(f"MET Variable: {met_var}") - if dict_items: - print(f"Dictionary Items:") - for item in dict_items: - print(f' {item}') - + print("\nNOTE: Text between lines that contain all dashes (-) should be " + "added or replaced in the files. Do not include the dash lines.") print('\n==================================================\n') - print(f'\n\nIn the {tool_name}_wrapper.py file, in the {wrapper_camel}Wrapper ' + print(f'In metplus/wrappers/{tool_name}_wrapper.py\n\n' + f'In the {wrapper_camel}Wrapper ' f'class, add the following to the WRAPPER_ENV_VAR_KEYS class ' - f"variable list:\n\n\n '{env_var_name}',\n\n") + f"variable list:\n" + "\n---------------------------------------------") + for var in met_vars: + print(f" '{var['env_var_name']}',") + print(f"---------------------------------------------\n") print('\n==================================================\n') + print(f'In metplus/wrappers/{tool_name}_wrapper.py\n\n') print(f'In the create_c_dict function for {wrapper_camel}Wrapper, add a ' 'function call to read the new METplus config variables and save ' - 'the value to be added to the wrapped MET config file.\n\n') - if not dict_items: - print(f" self.add_met_config(name='{met_var}',\n" - " data_type='',\n" - f" metplus_configs=['{metplus_var}'])" - "\n\n\n" - "where can be string, list, int, float, bool, " - "or thresh.\n\n") - else: - print("Typically a function is written to handle MET config dictionary" - " items. Search for functions that start with handle_ in " - "CommandBuilder or other parent class wrappers to see if a " - "function already exists for the item you are adding or to use " - "as an example to write a new one.\n\n") + 'the value to be added to the wrapped MET config file.\n') + print("\n---------------------------------------------") + for var in met_vars: + print_add_met_config(var) + print("---------------------------------------------\n" + "\nwhere DATA_TYPE can be string, list, int, float, bool, " + "or thresh. Refer to the METplus Contributor's Guide " + "Basic Components section to see how to add additional info.\n") + print("Sometimes a function is written to handle MET config dictionary" + " items that are complex and common to many wrappers." + " Search for functions that start with handle_ in " + "CommandBuilder or other parent class wrappers to see if a " + "function already exists for the item you are adding or to use " + "as an example to write a new one.\n\n") print('\n==================================================\n') print('Add the new variables to the basic use case example for the tool,\n' f'i.e. parm/use_cases/met_tool_wrapper/{wrapper_camel}/' - f'{wrapper_camel}.conf:\n\n') - for mp_config in metplus_config_names: - print(f'#{mp_config} =') + f'{wrapper_camel}.conf:\n' + "\n---------------------------------------------") + for var in met_vars: + for mp_config in var['metplus_config_names']: + print(f'#{mp_config} =') + + print("---------------------------------------------\n") print('\n\n==================================================\n') - print(f"In the parm/met_config/{wrapper_camel}Config_wrapped file, " - f"compare the default values set for {met_var} to the version" + + var_names = '/'.join([var['name'] for var in met_vars]) + print(f"In parm/met_config/{wrapper_camel}Config_wrapped\n\n" + "IMPORTANT: Compare the default values set for " + f"{var_names} " + "to the version" f" in share/met/config/{wrapper_camel}Config_default. If " "they do differ, make sure to add variables to the use case " "config files so that they produce the same output.\n\n") - print(f"In the parm/met_config/{wrapper_camel}Config_wrapped file, " - "replace:\n\n") - print(f"{met_var} = ...\n\n with:\n\n//{met_var} =" - f"{' {' if dict_items else ''}\n${{{env_var_name}}}\n\n") + + for var in met_vars: + print("REPLACE:\n" + "\n---------------------------------------------") + print(f"{var['name']} = ..." + "\n---------------------------------------------\n" + "\nwith:\n" + "\n---------------------------------------------\n" + f"//{var['name']} =" + f"{' {' if var['dict_items'] else ''}\n${{{var['env_var_name']}}}" + "\n---------------------------------------------\n") print('\n==================================================\n') - print(f"\n\nIn docs/Users_Guide/wrappers.rst under {wrapper_camel} => " - "METplus Configuration section, add:\n\n") - for metplus_config_name in metplus_config_names: - print(f'| :term:`{metplus_config_name}`') + print(f"\nIn docs/Users_Guide/wrappers.rst\n\n" + f"Under {wrapper_camel} => " + "METplus Configuration section, add:\n" + "\n---------------------------------------------") + for var in met_vars: + for metplus_config_name in var['metplus_config_names']: + print(f'| :term:`{metplus_config_name}`') + + print("---------------------------------------------\n") print('\n==================================================\n') - print(f"\n\nIn docs/Users_Guide/wrappers.rst under {wrapper_camel} => " - "MET Configuration section, add:\n\n") - var_header = (f"**${{{env_var_name}}}**") + print(f"\n\nIn docs/Users_Guide/wrappers.rst\n\n" + f"Under {wrapper_camel} => " + "MET Configuration section, add:\n" + "\n---------------------------------------------\n") + + for var in met_vars: + print_met_config_table(var) + + print("---------------------------------------------") + print('\n==================================================\n') + print(f"In docs/Users_Guide/glossary.rst" + "\n\nAdd the following anywhere in the file:\n") + print("---------------------------------------------\n") + + for var in met_vars: + print_glossary_entry(var, wrapper_camel) + + print("---------------------------------------------") + print('\n==================================================\n') + print(f"In internal_tests/pytests/{tool_name}/" + f"test_{tool_name}_wrapper.py" + "\n\nAdd the following items to " + "the tests to ensure the new items are set properly. Note: " + "if the tool does not have unit tests to check the handling of " + "MET config variables, you will need to add those tests. See " + "grid_stat/test_grid_stat_wrapper.py for an example. Change " + "VALUE to an appropriate value for the variable.\n\n") + + print("---------------------------------------------") + for var in met_vars: + print_unit_test(var) + print("---------------------------------------------") + # add note to test setting a valid value in the basic use case config file + # to ensure that it is formatted properly when read by the MET tool + print('\n==================================================\n') + print(f"In parm/use_cases/met_tool_wrapper/{wrapper_camel}" + "\n\nVerify that the new METplus configuration variable(s) " + "will be formatted properly when read by the MET tool by " + "setting the variable(s) in the basic use case config files " + "to a valid value " + "and run the use case to ensure that it still succeeds. " + "Be sure to remove the value and comment out the variable " + "after you have confirmed this step.") + print('\n==================================================\n') + +def print_add_met_config(var): + met_var = var['name'] + dict_items = var['dict_items'] + if not dict_items: + print(f" self.add_met_config(name='{met_var}',\n" + " data_type='DATA_TYPE')") + else: + print(f" self.add_met_config_dict('{met_var}', {{") + for item in dict_items: + print(f" '{item}': 'DATA_TYPE',") + print(" })") + print() + +def print_met_config_table(var): + env_var_name = var['env_var_name'] + metplus_names = var['metplus_config_names'] + met_names = var['met_config_names'] + var_header = (f"**${{{env_var_name}}}**") list_table_text = (f"{var_header}\n\n" ".. list-table::\n" " :widths: 5 5\n" @@ -163,33 +253,33 @@ def print_doc_text(tool_name, met_var, dict_items): " - MET Config File\n" ) - for metplus_config_name, met_config_name in zip(metplus_config_names, met_config_values): + for metplus_config_name, met_config_name in zip(metplus_names, met_names): list_table_text += (f" * - :term:`{metplus_config_name}`\n" f" - {met_config_name}\n" ) print(list_table_text) - print('\n==================================================\n') - print(f"In docs/Users_Guide/glossary.rst, add:\n\n") - for metplus_config_name, met_config_name in zip(metplus_config_names, met_config_values): - glossary_entry = (f" {metplus_config_name}\n" - f" Specify the value for '{met_config_name}' " - f"in the MET configuration file for {wrapper_camel}.\n\n" - f" | *Used by:* {wrapper_camel}") +def print_glossary_entry(var, wrapper_camel): + metplus_names = var['metplus_config_names'] + met_names = var['met_config_names'] + for metplus_config_name, met_config_name in zip(metplus_names, met_names): + glossary_entry = ( + f" {metplus_config_name}\n" + f" Specify the value for '{met_config_name}' " + f"in the MET configuration file for {wrapper_camel}.\n\n" + f" | *Used by:* {wrapper_camel}" + ) print(f'{glossary_entry}\n') - print('\n==================================================\n') - print(f"In internal_tests/pytests/{tool_name}/" - f"test_{tool_name}_wrapper.py, add the following items to " - "the tests to ensure the new items are set properly. Note: " - "if the tool does not have unit tests to check the handling of " - "MET config variables, you will need to add those tests. See " - "grid_stat/test_grid_stat_wrapper.py for an example. Change " - "VALUE to an appropriate value for the variable.\n\n") - +def print_unit_test(var): input_dict_items = [] output_items = [] - for metplus_config_name, met_config_name in zip(metplus_config_names, met_config_values): + metplus_names = var['metplus_config_names'] + met_names = var['met_config_names'] + dict_items = var['dict_items'] + env_var_name = var['env_var_name'] + var_name = var['name'] + for metplus_config_name, met_config_name in zip(metplus_names, met_names): if dict_items: item_name = met_config_name.split('.')[1] output_item = f"{item_name} = VALUE;" @@ -203,9 +293,8 @@ def print_doc_text(tool_name, met_var, dict_items): else: output_fmt = output_item - test_text = (f" ({{{mp_config_dict_item} }},\n" - f" {{'{env_var_name}': '{met_var} = " + f" {{'{env_var_name}': '{var_name} = " f"{output_fmt}'}}),\n") print(test_text) @@ -214,7 +303,7 @@ def print_doc_text(tool_name, met_var, dict_items): for input_dict_item in input_dict_items: all_items_text += f" {input_dict_item}\n" all_items_text += (" },\n" - f" {{'{env_var_name}': '{met_var} = {{") + f" {{'{env_var_name}': '{var_name} = {{") all_items_text += ''.join(output_items) all_items_text += "}'})," print(all_items_text) @@ -222,24 +311,29 @@ def print_doc_text(tool_name, met_var, dict_items): def doc_util_usage(): """! Print usage statement for script """ - print(f"{__file__} [ " []" ' + '" []"\n' + f"\nExample: {__file__} grid_stat output_prefix " + "\n (simple variable named output_prefix)\n" + f'\nExample: {__file__} grid_stat "output_flag fho ctc mctc" ' + '\n (dictionary named output_flag containing fho, ctc, and mctc)\n' + f'\nExample: {__file__} grid_stat "output_flag fho ctc mctc" ' + 'output_prefix \n (both of the variables from the previous ' + 'examples)\n') if __name__ == "__main__": # sys.argv[1] is MET tool name, i.e. grid_stat - # sys.argv[2] is MET variable name, i.e. output_flag - # sys.argv[3] is optional list of MET dictionary var items: fho ctc cts + # sys.argv[2+] is MET variable name, i.e. output_flag or a MET variable + # name followed by a list of MET dictionary var items separated by spaces if len(sys.argv) < 3: doc_util_usage() sys.exit(1) tool_name = sys.argv[1] - met_var = sys.argv[2] - dict_items = None - - if len(sys.argv) > 3: - items = ','.join(sys.argv[3:]).split(',') - dict_items = [item.strip() for item in items] + input_dict = {} + for arg in sys.argv[2:]: + var_name, *dict_items = arg.split() + input_dict[var_name] = dict_items - print_doc_text(tool_name, met_var, dict_items) + print_doc_text(tool_name, input_dict) diff --git a/metplus/util/met_config.py b/metplus/util/met_config.py index 847eb43e21..e8ef186e94 100644 --- a/metplus/util/met_config.py +++ b/metplus/util/met_config.py @@ -173,11 +173,10 @@ def add_met_config_dict(config, app_name, output_dict, dict_name, items): metplus_configs.append(f'{metplus_name}IN') metplus_configs.append(metplus_name) + # add other variable names to search if expected name is unset if nicknames: for nickname in nicknames: - metplus_configs.append( - f'{app_name}_{nickname}'.upper() - ) + metplus_configs.append(nickname) # if dictionary, read get children from MET config else: diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 3835d8b130..9b4ea34922 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -1685,6 +1685,7 @@ def handle_time_summary_dict(self): METPLUS_TIME_SUMMARY_DICT that is referenced in the wrapped MET config files. """ + app_upper = self.app_name.upper() self.add_met_config_dict('time_summary', { 'flag': 'bool', 'raw_data': 'bool', @@ -1693,12 +1694,15 @@ def handle_time_summary_dict(self): 'step': 'int', 'width': ('string', 'remove_quotes'), 'grib_code': ('list', 'remove_quotes,allow_empty', None, - ['TIME_SUMMARY_GRIB_CODES']), + [f'{app_upper}_TIME_SUMMARY_GRIB_CODES']), 'obs_var': ('list', 'allow_empty', None, - ['TIME_SUMMARY_VAR_NAMES']), - 'type': ('list', 'allow_empty', None, ['TIME_SUMMARY_TYPES']), - 'vld_freq': ('int', None, None, ['TIME_SUMMARY_VALID_FREQ']), - 'vld_thresh': ('float', None, None, ['TIME_SUMMARY_VALID_THRESH']), + [f'{app_upper}_TIME_SUMMARY_VAR_NAMES']), + 'type': ('list', 'allow_empty', None, + [f'{app_upper}_TIME_SUMMARY_TYPES']), + 'vld_freq': ('int', None, None, + [f'{app_upper}_TIME_SUMMARY_VALID_FREQ']), + 'vld_thresh': ('float', None, None, + [f'{app_upper}_TIME_SUMMARY_VALID_THRESH']), }) def handle_mask(self, single_value=False, get_flags=False): @@ -1709,12 +1713,13 @@ def handle_mask(self, single_value=False, get_flags=False): @param get_flags if True, read grid_flag and poly_flag values """ data_type = 'string' if single_value else 'list' - + app_upper = self.app_name.upper() items = { 'grid': (data_type, 'allow_empty', None, - ['GRID']), + [f'{app_upper}_GRID']), 'poly': (data_type, 'allow_empty', None, - ['VERIFICATION_MASK_TEMPLATE', 'POLY']), + [f'{app_upper}_VERIFICATION_MASK_TEMPLATE', + f'{app_upper}_POLY']), } if get_flags: diff --git a/metplus/wrappers/compare_gridded_wrapper.py b/metplus/wrappers/compare_gridded_wrapper.py index f90e7c0567..e9c4e2bafe 100755 --- a/metplus/wrappers/compare_gridded_wrapper.py +++ b/metplus/wrappers/compare_gridded_wrapper.py @@ -404,7 +404,8 @@ def get_command(self): def handle_climo_cdf_dict(self): self.add_met_config_dict('climo_cdf', { - 'cdf_bins': ('float', None, None, ['CLIMO_CDF_BINS']), + 'cdf_bins': ('float', None, None, + [f'{self.app_name.upper()}_CLIMO_CDF_BINS']), 'center_bins': 'bool', 'write_bins': 'bool', }) diff --git a/metplus/wrappers/grid_stat_wrapper.py b/metplus/wrappers/grid_stat_wrapper.py index 2ad1d011a2..35451c9ff2 100755 --- a/metplus/wrappers/grid_stat_wrapper.py +++ b/metplus/wrappers/grid_stat_wrapper.py @@ -49,6 +49,9 @@ class GridStatWrapper(CompareGriddedWrapper): 'METPLUS_OBS_FILE_TYPE', 'METPLUS_HSS_EC_VALUE', 'METPLUS_DISTANCE_MAP_DICT', + 'METPLUS_FOURIER_DICT', + 'METPLUS_CENSOR_THRESH', + 'METPLUS_CENSOR_VAL', ] # handle deprecated env vars used pre v4.0.0 @@ -246,6 +249,19 @@ def create_c_dict(self): 'beta_value(n)': ('string', 'remove_quotes'), }) + self.add_met_config_dict('fourier', { + 'wave_1d_beg': ('list', 'remove_quotes'), + 'wave_1d_end': ('list', 'remove_quotes'), + }) + + self.add_met_config(name='censor_thresh', + data_type='list', + extra_args={'remove_quotes': True}) + + self.add_met_config(name='censor_val', + data_type='list', + extra_args={'remove_quotes': True}) + return c_dict def set_environment_variables(self, time_info): diff --git a/metplus/wrappers/mode_wrapper.py b/metplus/wrappers/mode_wrapper.py index af4237c4d7..23f958ac10 100755 --- a/metplus/wrappers/mode_wrapper.py +++ b/metplus/wrappers/mode_wrapper.py @@ -56,6 +56,8 @@ class MODEWrapper(CompareGriddedWrapper): 'METPLUS_INTEREST_FUNCTION_CENTROID_DIST', 'METPLUS_INTEREST_FUNCTION_BOUNDARY_DIST', 'METPLUS_INTEREST_FUNCTION_CONVEX_HULL_DIST', + 'METPLUS_PS_PLOT_FLAG', + 'METPLUS_CT_STATS_FLAG', ] WEIGHTS = { @@ -347,6 +349,13 @@ def create_c_dict(self): } ) + self.add_met_config(name='ps_plot_flag', + data_type='bool') + + self.add_met_config(name='ct_stats_flag', + data_type='bool') + + c_dict['ALLOW_MULTIPLE_FILES'] = False c_dict['MERGE_CONFIG_FILE'] = ( diff --git a/metplus/wrappers/pb2nc_wrapper.py b/metplus/wrappers/pb2nc_wrapper.py index f571934f3c..31828f145b 100755 --- a/metplus/wrappers/pb2nc_wrapper.py +++ b/metplus/wrappers/pb2nc_wrapper.py @@ -35,6 +35,8 @@ class PB2NCWrapper(CommandBuilder): 'METPLUS_LEVEL_RANGE_DICT', 'METPLUS_LEVEL_CATEGORY', 'METPLUS_QUALITY_MARK_THRESH', + 'METPLUS_OBS_BUFR_MAP', + 'METPLUS_OBS_PREPBUFR_MAP', ] def __init__(self, config, instance=None, config_overrides=None): @@ -187,6 +189,14 @@ def create_c_dict(self): data_type='int', metplus_configs=['PB2NC_QUALITY_MARK_THRESH']) + self.add_met_config(name='obs_bufr_map', + data_type='list', + extra_args={'remove_quotes': True}) + + self.add_met_config(name='obs_prepbufr_map', + data_type='list', + extra_args={'remove_quotes': True}) + return c_dict def set_environment_variables(self, time_info): diff --git a/metplus/wrappers/point_stat_wrapper.py b/metplus/wrappers/point_stat_wrapper.py index c47673a5a4..669ab8252c 100755 --- a/metplus/wrappers/point_stat_wrapper.py +++ b/metplus/wrappers/point_stat_wrapper.py @@ -32,6 +32,7 @@ class PointStatWrapper(CompareGriddedWrapper): 'METPLUS_MASK_GRID', 'METPLUS_MASK_POLY', 'METPLUS_MASK_SID', + 'METPLUS_MASK_LLPNT', 'METPLUS_OUTPUT_PREFIX', 'METPLUS_CLIMO_CDF_DICT', 'METPLUS_OBS_QUALITY_INC', @@ -41,6 +42,8 @@ class PointStatWrapper(CompareGriddedWrapper): 'METPLUS_CLIMO_MEAN_DICT', 'METPLUS_CLIMO_STDEV_DICT', 'METPLUS_HSS_EC_VALUE', + 'METPLUS_HIRA_DICT', + 'METPLUS_MESSAGE_TYPE_GROUP_MAP', ] # handle deprecated env vars used pre v4.0.0 @@ -165,6 +168,13 @@ def create_c_dict(self): 'POINT_STAT_STATION_ID'], extra_args={'allow_empty': True}) + self.add_met_config(name='llpnt', + data_type='list', + env_var_name='METPLUS_MASK_LLPNT', + metplus_configs=['POINT_STAT_MASK_LLPNT'], + extra_args={'allow_empty': True, + 'remove_quotes': True}) + self.add_met_config(name='message_type', data_type='list') @@ -231,6 +241,19 @@ def create_c_dict(self): data_type='float', metplus_configs=['POINT_STAT_HSS_EC_VALUE']) + self.add_met_config_dict('hira', { + 'flag': 'bool', + 'width': ('list', 'remove_quotes'), + 'vld_thresh': 'float', + 'cov_thresh': ('list', 'remove_quotes'), + 'shape': ('string', 'remove_quotes, uppercase'), + 'prob_cat_thresh': ('list', 'remove_quotes'), + }) + + self.add_met_config(name='message_type_group_map', + data_type='list', + extra_args={'remove_quotes': True}) + if not c_dict['FCST_INPUT_TEMPLATE']: self.log_error('Must set FCST_POINT_STAT_INPUT_TEMPLATE ' 'in config file') diff --git a/metplus/wrappers/series_analysis_wrapper.py b/metplus/wrappers/series_analysis_wrapper.py index 8a16f4e2dd..8fdd625079 100755 --- a/metplus/wrappers/series_analysis_wrapper.py +++ b/metplus/wrappers/series_analysis_wrapper.py @@ -51,8 +51,7 @@ class SeriesAnalysisWrapper(RuntimeFreqWrapper): 'METPLUS_CLIMO_STDEV_DICT', 'METPLUS_BLOCK_SIZE', 'METPLUS_VLD_THRESH', - 'METPLUS_CTS_LIST', - 'METPLUS_STAT_LIST', + 'METPLUS_OUTPUT_STATS_DICT', 'METPLUS_HSS_EC_VALUE', ] @@ -60,6 +59,24 @@ class SeriesAnalysisWrapper(RuntimeFreqWrapper): DEPRECATED_WRAPPER_ENV_VAR_KEYS = [ 'CLIMO_MEAN_FILE', 'CLIMO_STDEV_FILE', + 'METPLUS_CTS_LIST', + 'METPLUS_STAT_LIST', + ] + + # variable names of output_stats dictionary + OUTPUT_STATS = [ + 'fho', + 'ctc', + 'cts', + 'mctc', + 'mcts', + 'cnt', + 'sl1l2', + 'sal1l2', + 'pct', + 'pstd', + 'pjc', + 'prc', ] def __init__(self, config, instance=None, config_overrides=None): @@ -118,23 +135,42 @@ def create_c_dict(self): data_type='string', extra_args={'remove_quotes': True}) - # get stat list to loop over - c_dict['STAT_LIST'] = getlist( - self.config.getstr('config', - 'SERIES_ANALYSIS_STAT_LIST', - '') - ) + # handle all output_stats dictionary values + output_stats_dict = {} + for key in self.OUTPUT_STATS: + nicknames = [ + f'SERIES_ANALYSIS_OUTPUT_STATS_{key.upper()}', + f'SERIES_ANALYSIS_{key.upper()}_LIST', + f'SERIES_ANALYSIS_{key.upper()}' + ] + # add legacy support for STAT_LIST for cnt + if key == 'cnt': + nicknames.append('SERIES_ANALYSIS_STAT_LIST') + # read cnt stat list to get stats to loop over for plotting + self.add_met_config(name='cnt', + data_type='list', + env_var_name='STAT_LIST', + metplus_configs=nicknames) + c_dict['STAT_LIST'] = getlist( + self.get_env_var_value('METPLUS_STAT_LIST') + ) + + value = ('list', None, None, nicknames) + output_stats_dict[key] = value + self.add_met_config_dict('output_stats', output_stats_dict) + if not c_dict['STAT_LIST']: self.log_error("Must set SERIES_ANALYSIS_STAT_LIST to run.") - # set stat list to set output_stats.cnt in MET config file + + # set legacy stat list to set output_stats.cnt in MET config file self.add_met_config(name='cnt', data_type='list', env_var_name='METPLUS_STAT_LIST', metplus_configs=['SERIES_ANALYSIS_STAT_LIST', 'SERIES_ANALYSIS_CNT']) - # set cts list to set output_stats.cts in MET config file + # set legacy cts list to set output_stats.cts in MET config file self.add_met_config(name='cts', data_type='list', env_var_name='METPLUS_CTS_LIST', diff --git a/metplus/wrappers/tc_pairs_wrapper.py b/metplus/wrappers/tc_pairs_wrapper.py index c3e6a67a5b..369779ed43 100755 --- a/metplus/wrappers/tc_pairs_wrapper.py +++ b/metplus/wrappers/tc_pairs_wrapper.py @@ -61,6 +61,8 @@ class TCPairsWrapper(CommandBuilder): 'METPLUS_WRITE_VALID', 'METPLUS_VALID_INC', 'METPLUS_VALID_EXC', + 'METPLUS_CHECK_DUP', + 'METPLUS_INTERP12', ] WILDCARDS = { @@ -169,6 +171,14 @@ def create_c_dict(self): self.handle_consensus() + self.add_met_config(name='check_dup', + data_type='bool') + + self.add_met_config(name='interp12', + data_type='string', + extra_args={'remove_quotes': True, + 'uppercase': True}) + c_dict['INIT_INCLUDE'] = getlist( self.get_wrapper_or_generic_config('INIT_INCLUDE') ) diff --git a/parm/met_config/GridStatConfig_wrapped b/parm/met_config/GridStatConfig_wrapped index fdaf8cf209..29abe3a6d2 100644 --- a/parm/met_config/GridStatConfig_wrapped +++ b/parm/met_config/GridStatConfig_wrapped @@ -35,8 +35,10 @@ ${METPLUS_REGRID_DICT} //////////////////////////////////////////////////////////////////////////////// -censor_thresh = []; -censor_val = []; +//censor_thresh = +${METPLUS_CENSOR_THRESH} +//censor_val = +${METPLUS_CENSOR_VAL} cat_thresh = []; cnt_thresh = [ NA ]; cnt_logic = UNION; @@ -134,10 +136,8 @@ nbrhd = { // Fourier decomposition // May be set separately in each "obs.field" entry // -fourier = { - wave_1d_beg = []; - wave_1d_end = []; -} +//fourier = { +${METPLUS_FOURIER_DICT} //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/MODEConfig_wrapped b/parm/met_config/MODEConfig_wrapped index 5f0442addc..a5ae8e4d4b 100644 --- a/parm/met_config/MODEConfig_wrapped +++ b/parm/met_config/MODEConfig_wrapped @@ -210,12 +210,15 @@ plot_gcarc_flag = FALSE; // // NetCDF matched pairs, PostScript, and contingency table output files // -ps_plot_flag = TRUE; +//ps_plot_flag = +${METPLUS_PS_PLOT_FLAG} //nc_pairs_flag = { ${METPLUS_NC_PAIRS_FLAG_DICT} -ct_stats_flag = TRUE; +//ct_stats_flag = +${METPLUS_CT_STATS_FLAG} + //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/PB2NCConfig_wrapped b/parm/met_config/PB2NCConfig_wrapped index 25cab0375b..4701ccf25c 100644 --- a/parm/met_config/PB2NCConfig_wrapped +++ b/parm/met_config/PB2NCConfig_wrapped @@ -94,26 +94,13 @@ ${METPLUS_OBS_BUFR_VAR} // Mapping of BUFR variable name to GRIB name. The default map is defined at // obs_prepbufr_map. This replaces/expends the default map. // -obs_bufr_map = []; +//obs_bufr_map = +${METPLUS_OBS_BUFR_MAP} // This map is for PREPBUFR. It will be added into obs_bufr_map. // Please do not override this map. -obs_prefbufr_map = [ - { key = "POB"; val = "PRES"; }, - { key = "QOB"; val = "SPFH"; }, - { key = "TOB"; val = "TMP"; }, - { key = "ZOB"; val = "HGT"; }, - { key = "UOB"; val = "UGRD"; }, - { key = "VOB"; val = "VGRD"; }, - { key = "D_DPT"; val = "DPT"; }, - { key = "D_WDIR"; val = "WDIR"; }, - { key = "D_WIND"; val = "WIND"; }, - { key = "D_RH"; val = "RH"; }, - { key = "D_MIXR"; val = "MIXR"; }, - { key = "D_PRMSL"; val = "PRMSL"; }, - { key = "D_PBL"; val = "PBL"; }, - { key = "D_CAPE"; val = "CAPE"; } -]; +//obs_prepbufr_map = +${METPLUS_OBS_PREPBUFR_MAP} //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/PointStatConfig_wrapped b/parm/met_config/PointStatConfig_wrapped index 824aed7145..9ab367e182 100644 --- a/parm/met_config/PointStatConfig_wrapped +++ b/parm/met_config/PointStatConfig_wrapped @@ -77,14 +77,8 @@ obs_perc_value = 50; // // Mapping of message type group name to comma-separated list of values. // -message_type_group_map = [ - { key = "SURFACE"; val = "ADPSFC,SFCSHP,MSONET"; }, - { key = "ANYAIR"; val = "AIRCAR,AIRCFT"; }, - { key = "ANYSFC"; val = "ADPSFC,SFCSHP,ADPUPA,PROFLR,MSONET"; }, - { key = "ONLYSF"; val = "ADPSFC,SFCSHP"; }, - { key = "LANDSF"; val = "ADPSFC,MSONET"; }, - { key = "WATERSF"; val = "SFCSHP"; } -]; +//message_type_group_map = +${METPLUS_MESSAGE_TYPE_GROUP_MAP} //////////////////////////////////////////////////////////////////////////////// @@ -121,7 +115,8 @@ mask = { ${METPLUS_MASK_GRID} ${METPLUS_MASK_POLY} ${METPLUS_MASK_SID} - llpnt = []; + //llpnt = + ${METPLUS_MASK_LLPNT} } //////////////////////////////////////////////////////////////////////////////// @@ -152,13 +147,8 @@ ${METPLUS_INTERP_DICT} // // HiRA verification method // -hira = { - flag = FALSE; - width = [ 2, 3, 4, 5 ]; - vld_thresh = 1.0; - cov_thresh = [ ==0.25 ]; - shape = SQUARE; -} +//hira = { +${METPLUS_HIRA_DICT} //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/SeriesAnalysisConfig_wrapped b/parm/met_config/SeriesAnalysisConfig_wrapped index 94fd1b2629..a8cf1af226 100644 --- a/parm/met_config/SeriesAnalysisConfig_wrapped +++ b/parm/met_config/SeriesAnalysisConfig_wrapped @@ -102,20 +102,8 @@ ${METPLUS_VLD_THRESH} // // Statistical output types // -output_stats = { - fho = []; - ctc = []; - ${METPLUS_CTS_LIST} - mctc = []; - mcts = []; - ${METPLUS_STAT_LIST} - sl1l2 = []; - sal1l2 = []; - pct = []; - pstd = []; - pjc = []; - prc = []; -} +//output_stats = { +${METPLUS_OUTPUT_STATS_DICT} //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/met_config/TCPairsConfig_wrapped b/parm/met_config/TCPairsConfig_wrapped index ce13c1db82..67246863de 100644 --- a/parm/met_config/TCPairsConfig_wrapped +++ b/parm/met_config/TCPairsConfig_wrapped @@ -82,13 +82,16 @@ valid_mask = ""; // // Specify if the code should check for duplicate ATCF lines // -check_dup = FALSE; +//check_dup = +${METPLUS_CHECK_DUP} + // // Specify special processing to be performed for interpolated models. // Set to NONE, FILL, or REPLACE. // -interp12 = REPLACE; +//interp12 = +${METPLUS_INTERP12} // // Specify how consensus forecasts should be defined diff --git a/parm/use_cases/met_tool_wrapper/GridStat/GridStat.conf b/parm/use_cases/met_tool_wrapper/GridStat/GridStat.conf index d9d6d475ef..e4316e8641 100644 --- a/parm/use_cases/met_tool_wrapper/GridStat/GridStat.conf +++ b/parm/use_cases/met_tool_wrapper/GridStat/GridStat.conf @@ -174,3 +174,9 @@ GRID_STAT_NC_PAIRS_FLAG_APPLY_MASK = FALSE #GRID_STAT_DISTANCE_MAP_FOM_ALPHA = #GRID_STAT_DISTANCE_MAP_ZHU_WEIGHT = #GRID_STAT_DISTANCE_MAP_BETA_VALUE_N = + +#GRID_STAT_FOURIER_WAVE_1D_BEG = +#GRID_STAT_FOURIER_WAVE_1D_END = + +#GRID_STAT_CENSOR_THRESH = +#GRID_STAT_CENSOR_VAL = diff --git a/parm/use_cases/met_tool_wrapper/MODE/MODE.conf b/parm/use_cases/met_tool_wrapper/MODE/MODE.conf index 35bc4c2f6d..6cbc269527 100644 --- a/parm/use_cases/met_tool_wrapper/MODE/MODE.conf +++ b/parm/use_cases/met_tool_wrapper/MODE/MODE.conf @@ -1,7 +1,5 @@ [config] -# MODE METplus Configuration - PROCESS_LIST = MODE LOOP_ORDER = times @@ -119,3 +117,6 @@ MODE_GRID_RES = 40 #MODE_NC_PAIRS_FLAG_POLYLINES = MODE_QUILT = True + +#MODE_PS_PLOT_FLAG = +#MODE_CT_STATS_FLAG = diff --git a/parm/use_cases/met_tool_wrapper/PB2NC/PB2NC.conf b/parm/use_cases/met_tool_wrapper/PB2NC/PB2NC.conf index 14cc9b405f..a919fc00b9 100644 --- a/parm/use_cases/met_tool_wrapper/PB2NC/PB2NC.conf +++ b/parm/use_cases/met_tool_wrapper/PB2NC/PB2NC.conf @@ -1,78 +1,34 @@ -# PrepBufr to NetCDF Configurations - -# section heading for [config] variables - all items below this line and -# before the next section heading correspond to the [config] section [config] -# List of applications to run - only PB2NC for this case PROCESS_LIST = PB2NC -# time looping - options are INIT, VALID, RETRO, and REALTIME -# If set to INIT or RETRO: -# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set -# If set to VALID or REALTIME: -# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set LOOP_BY = VALID - -# Format of VALID_BEG and VALID_END using % items -# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. -# see www.strftime.org for more information -# %Y%m%d%H expands to YYYYMMDDHH VALID_TIME_FMT = %Y%m%d%H - -# Start time for METplus run - must match VALID_TIME_FMT VALID_BEG = 2007033112 - -# End time for METplus run - must match VALID_TIME_FMT VALID_END = 2007033112 - -# Increment between METplus runs (in seconds if no units are specified) -# Must be >= 60 seconds VALID_INCREMENT = 1M -# List of forecast leads to process for each run time (init or valid) -# In hours if units are not specified -# If unset, defaults to 0 (don't loop through forecast leads) LEAD_SEQ = 0 -# list of offsets in the prepBUFR input filenames to allow. List is in order of preference -# i.e. if 12, 6 is listed, it will try to use a 12 offset file and then try to use a 6 offset -# if the 12 does not exist PB2NC_OFFSETS = 12 -# Order of loops to process data - Options are times, processes -# Not relevant if only one item is in the PROCESS_LIST -# times = run all wrappers in the PROCESS_LIST for a single run time, then -# increment the run time and run all wrappers again until all times have -# been evaluated. -# processes = run the first wrapper in the PROCESS_LIST for all times -# specified, then repeat for the next item in the PROCESS_LIST until all -# wrappers have been run -LOOP_ORDER = processes - -# Location of MET config file to pass to PB2NC -# References CONFIG_DIR from the [dir] section -PB2NC_CONFIG_FILE = {CONFIG_DIR}/PB2NCConfig_wrapped - -# If set to True, skip run if the output file determined by the output directory and -# filename template already exists PB2NC_SKIP_IF_OUTPUT_EXISTS = True -# Time relative to each input file's valid time (in seconds if no units are specified) for data within the file to be -# considered valid. Values are set in the 'obs_window' dictionary in the PB2NC config file +PB2NC_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_obs/prepbufr +PB2NC_INPUT_TEMPLATE = ndas.t{da_init?fmt=%2H}z.prepbufr.tm{offset?fmt=%2H}.{da_init?fmt=%Y%m%d}.nr + +PB2NC_OUTPUT_DIR = {OUTPUT_BASE}/pb2nc +PB2NC_OUTPUT_TEMPLATE = sample_pb.nc + + +PB2NC_CONFIG_FILE = {PARM_BASE}/met_config/PB2NCConfig_wrapped + PB2NC_WINDOW_BEGIN = -1800 PB2NC_WINDOW_END = 1800 -# controls the window of time around the current run time to be considered to be valid for all -# input files, not just relative to each input files valid time -# if set, these values are substituted with the times from the current run time and passed to -# PB2NC on the command line with -valid_beg and -valid_end arguments. -# Not used if unset or set to an empty string PB2NC_VALID_BEGIN = {valid?fmt=%Y%m%d_%H} PB2NC_VALID_END = {valid?fmt=%Y%m%d_%H?shift=1d} -# Values to pass to pb2nc config file using environment variables of the same name. -# See MET User's Guide for more information PB2NC_GRID = G212 PB2NC_POLY = PB2NC_STATION_ID = @@ -90,8 +46,6 @@ PB2NC_QUALITY_MARK_THRESH = 3 # Leave empty to process all PB2NC_OBS_BUFR_VAR_LIST = QOB, TOB, ZOB, UOB, VOB, D_DPT, D_WIND, D_RH, D_MIXR -# For defining the time periods for summarization - PB2NC_TIME_SUMMARY_FLAG = False PB2NC_TIME_SUMMARY_BEG = 000000 PB2NC_TIME_SUMMARY_END = 235959 @@ -105,22 +59,5 @@ PB2NC_TIME_SUMMARY_GRIB_CODES = PB2NC_TIME_SUMMARY_VALID_FREQ = 0 PB2NC_TIME_SUMMARY_VALID_THRESH = 0.0 -# End of [config] section and start of [dir] section -[dir] -# location of configuration files used by MET applications -CONFIG_DIR = {PARM_BASE}/met_config - -# directory containing input to PB2NC -PB2NC_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_obs/prepbufr - -# directory to write output from PB2NC -PB2NC_OUTPUT_DIR = {OUTPUT_BASE}/pb2nc - - -# End of [dir] section and start of [filename_templates] section -[filename_templates] -# Template to look for forecast input to PB2NC relative to PB2NC_INPUT_DIR -PB2NC_INPUT_TEMPLATE = ndas.t{da_init?fmt=%2H}z.prepbufr.tm{offset?fmt=%2H}.{da_init?fmt=%Y%m%d}.nr - -# Template to use to write output from PB2NC -PB2NC_OUTPUT_TEMPLATE = sample_pb.nc +#PB2NC_OBS_BUFR_MAP = +#PB2NC_OBS_PREPBUFR_MAP = diff --git a/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf b/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf index 36875e0a79..cc43470edb 100644 --- a/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf +++ b/parm/use_cases/met_tool_wrapper/PointStat/PointStat.conf @@ -1,56 +1,73 @@ [config] -# List of applications to run - only PointStat for this case PROCESS_LIST = PointStat -# time looping - options are INIT, VALID, RETRO, and REALTIME -# If set to INIT or RETRO: -# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set -# If set to VALID or REALTIME: -# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set -LOOP_BY = INIT +### +# Time Info +### -# Format of INIT_BEG and INIT_END using % items -# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. -# see www.strftime.org for more information -# %Y%m%d%H expands to YYYYMMDDHH +LOOP_BY = INIT INIT_TIME_FMT = %Y%m%d - -# Start time for METplus run - must match INIT_TIME_FMT INIT_BEG = 20070330 - -# End time for METplus run - must match INIT_TIME_FMT INIT_END = 20070330 - -# Increment between METplus runs (in seconds if no units are specified) -# Must be >= 60 seconds INIT_INCREMENT = 1M -# List of forecast leads to process for each run time (init or valid) -# In hours if units are not specified -# If unset, defaults to 0 (don't loop through forecast leads) LEAD_SEQ = 36 -# Order of loops to process data - Options are times, processes -# Not relevant if only one item is in the PROCESS_LIST -# times = run all wrappers in the PROCESS_LIST for a single run time, then -# increment the run time and run all wrappers again until all times have -# been evaluated. -# processes = run the first wrapper in the PROCESS_LIST for all times -# specified, then repeat for the next item in the PROCESS_LIST until all -# wrappers have been run -LOOP_ORDER = processes - -# Verbosity of MET output - overrides LOG_VERBOSITY for PointStat only +### +# File I/O +### + +FCST_POINT_STAT_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst +FCST_POINT_STAT_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/nam.t00z.awip1236.tm00.{init?fmt=%Y%m%d}.grb + +OBS_POINT_STAT_INPUT_DIR = {INPUT_BASE}/met_test/out/pb2nc +OBS_POINT_STAT_INPUT_TEMPLATE = sample_pb.nc + +POINT_STAT_OUTPUT_DIR = {OUTPUT_BASE}/point_stat + +POINT_STAT_CLIMO_MEAN_INPUT_DIR = +POINT_STAT_CLIMO_MEAN_INPUT_TEMPLATE = + +POINT_STAT_CLIMO_STDEV_INPUT_DIR = +POINT_STAT_CLIMO_STDEV_INPUT_TEMPLATE = + + +### +# Field Info +### + +POINT_STAT_ONCE_PER_FIELD = False + +FCST_VAR1_NAME = TMP +FCST_VAR1_LEVELS = P750-900 +FCST_VAR1_THRESH = <=273, >273 +OBS_VAR1_NAME = TMP +OBS_VAR1_LEVELS = P750-900 +OBS_VAR1_THRESH = <=273, >273 + +FCST_VAR2_NAME = UGRD +FCST_VAR2_LEVELS = Z10 +FCST_VAR2_THRESH = >=5 +OBS_VAR2_NAME = UGRD +OBS_VAR2_LEVELS = Z10 +OBS_VAR2_THRESH = >=5 + +FCST_VAR3_NAME = VGRD +FCST_VAR3_LEVELS = Z10 +FCST_VAR3_THRESH = >=5 +OBS_VAR3_NAME = VGRD +OBS_VAR3_LEVELS = Z10 +OBS_VAR3_THRESH = >=5 + +### +# PointStat +### + #LOG_POINT_STAT_VERBOSITY = 2 -# Location of MET config file to pass to GridStat -# References PARM_BASE which is the location of the parm directory corresponding -# to the ush directory of the run_metplus.py script that is called -# or the value of the environment variable METPLUS_PARM_BASE if set POINT_STAT_CONFIG_FILE ={PARM_BASE}/met_config/PointStatConfig_wrapped - #POINT_STAT_OBS_QUALITY_INC = 1, 2, 3 #POINT_STAT_OBS_QUALITY_EXC = @@ -89,115 +106,37 @@ POINT_STAT_OUTPUT_FLAG_VL1L2 = STAT #POINT_STAT_HSS_EC_VALUE = -# Time relative to each input file's valid time (in seconds if no units are specified) for data within the file to be -# considered valid. Values are set in the 'obs_window' dictionary in the PointStat config file OBS_POINT_STAT_WINDOW_BEGIN = -5400 OBS_POINT_STAT_WINDOW_END = 5400 -# Optional list of offsets to look for point observation data POINT_STAT_OFFSETS = 0 -# Model/fcst and obs name, e.g. GFS, NAM, GDAS, etc. MODEL = WRF POINT_STAT_DESC = NA OBTYPE = -# Regrid to specified grid. Indicate NONE if no regridding, or the grid id -# (e.g. G212) POINT_STAT_REGRID_TO_GRID = NONE POINT_STAT_REGRID_METHOD = BILIN POINT_STAT_REGRID_WIDTH = 2 POINT_STAT_OUTPUT_PREFIX = -# sets the -obs_valid_beg command line argument (optional) -# not used for this example #POINT_STAT_OBS_VALID_BEG = {valid?fmt=%Y%m%d_%H} - -# sets the -obs_valid_end command line argument (optional) -# not used for this example #POINT_STAT_OBS_VALID_END = {valid?fmt=%Y%m%d_%H} -# Verification Masking regions -# Indicate which grid and polygon masking region, if applicable -POINT_STAT_GRID = DTC165, DTC166 - -# List of full path to poly masking files. NOTE: Only short lists of poly -# files work (those that fit on one line), a long list will result in an -# environment variable that is too long, resulting in an error. For long -# lists of poly masking files (i.e. all the mask files in the NCEP_mask -# directory), define these in the MET point_stat configuration file. -POINT_STAT_POLY = MET_BASE/poly/LMV.poly -POINT_STAT_STATION_ID = +POINT_STAT_MASK_GRID = DTC165, DTC166 +POINT_STAT_MASK_POLY = MET_BASE/poly/LMV.poly +POINT_STAT_MASK_SID = +#POINT_STAT_MASK_LLPNT = -# Message types, if all message types are to be returned, leave this empty, -# otherwise indicate the message types of interest. POINT_STAT_MESSAGE_TYPE = ADPUPA, ADPSFC -# Variables and levels as specified in the field dictionary of the MET -# point_stat configuration file. Specify as FCST_VARn_NAME, FCST_VARn_LEVELS, -# (optional) FCST_VARn_OPTION - -# set to True to run PointStat once for each name/level combination -# set to False to run PointStat once per run time including all fields -POINT_STAT_ONCE_PER_FIELD = False - -# fields to compare -# Note: If FCST_VAR_* is set, then a corresponding OBS_VAR_* variable must be set -# To use one variables for both forecast and observation data, set BOTH_VAR_* instead -FCST_VAR1_NAME = TMP -FCST_VAR1_LEVELS = P750-900 -FCST_VAR1_THRESH = <=273, >273 -OBS_VAR1_NAME = TMP -OBS_VAR1_LEVELS = P750-900 -OBS_VAR1_THRESH = <=273, >273 - -FCST_VAR2_NAME = UGRD -FCST_VAR2_LEVELS = Z10 -FCST_VAR2_THRESH = >=5 -OBS_VAR2_NAME = UGRD -OBS_VAR2_LEVELS = Z10 -OBS_VAR2_THRESH = >=5 - -FCST_VAR3_NAME = VGRD -FCST_VAR3_LEVELS = Z10 -FCST_VAR3_THRESH = >=5 -OBS_VAR3_NAME = VGRD -OBS_VAR3_LEVELS = Z10 -OBS_VAR3_THRESH = >=5 - - -# End of [config] section and start of [dir] section -[dir] -FCST_POINT_STAT_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst -OBS_POINT_STAT_INPUT_DIR = {INPUT_BASE}/met_test/out/pb2nc - -# directory containing climatology mean input to PointStat -# Not used in this example -POINT_STAT_CLIMO_MEAN_INPUT_DIR = - -# directory containing climatology mean input to PointStat -# Not used in this example -POINT_STAT_CLIMO_STDEV_INPUT_DIR = - -POINT_STAT_OUTPUT_DIR = {OUTPUT_BASE}/point_stat - - -# End of [dir] section and start of [filename_templates] section -[filename_templates] - -# Template to look for forecast input to PointStat relative to FCST_POINT_STAT_INPUT_DIR -FCST_POINT_STAT_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/nam.t00z.awip1236.tm00.{init?fmt=%Y%m%d}.grb - -# Template to look for observation input to PointStat relative to OBS_POINT_STAT_INPUT_DIR -OBS_POINT_STAT_INPUT_TEMPLATE = sample_pb.nc - -# Template to look for climatology input to PointStat relative to POINT_STAT_CLIMO_MEAN_INPUT_DIR -# Not used in this example -POINT_STAT_CLIMO_MEAN_INPUT_TEMPLATE = - -# Template to look for climatology input to PointStat relative to POINT_STAT_CLIMO_STDEV_INPUT_DIR -# Not used in this example -POINT_STAT_CLIMO_STDEV_INPUT_TEMPLATE = +#POINT_STAT_HIRA_FLAG = +#POINT_STAT_HIRA_WIDTH = +#POINT_STAT_HIRA_VLD_THRESH = +#POINT_STAT_HIRA_COV_THRESH = +#POINT_STAT_HIRA_SHAPE = +#POINT_STAT_HIRA_PROB_CAT_THRESH = +#POINT_STAT_MESSAGE_TYPE_GROUP_MAP = diff --git a/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf b/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf index 8f450802a1..230c081f1d 100644 --- a/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf +++ b/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf @@ -1,54 +1,86 @@ -# SeriesAnalysis METplus Configuration - [config] PROCESS_LIST = SeriesAnalysis -LOOP_BY = INIT +### +# Time Info +### +LOOP_BY = INIT INIT_TIME_FMT = %Y%m%d%H - INIT_BEG=2005080700 - INIT_END=2005080700 - INIT_INCREMENT = 12H LEAD_SEQ = 12, 9, 6 -SERIES_ANALYSIS_CUSTOM_LOOP_LIST = +SERIES_ANALYSIS_RUNTIME_FREQ = RUN_ONCE_PER_INIT_OR_VALID -LOOP_ORDER = processes +SERIES_ANALYSIS_RUN_ONCE_PER_STORM_ID = False -#LOG_SERIES_ANALYSIS_VERBOSITY = 2 +SERIES_ANALYSIS_CUSTOM_LOOP_LIST = -SERIES_ANALYSIS_IS_PAIRED = False +### +# File I/O +### -SERIES_ANALYSIS_GENERATE_PLOTS = no +FCST_SERIES_ANALYSIS_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst +FCST_SERIES_ANALYSIS_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/wrfprs_ruc13_{lead?fmt=%2H}.tm00_G212 -PLOT_DATA_PLANE_TITLE = +OBS_SERIES_ANALYSIS_INPUT_DIR = {INPUT_BASE}/met_test/new +OBS_SERIES_ANALYSIS_INPUT_TEMPLATE = ST2ml{valid?fmt=%Y%m%d%H}_A03h.nc -SERIES_ANALYSIS_GENERATE_ANIMATIONS = no +SERIES_ANALYSIS_TC_STAT_INPUT_DIR = +SERIES_ANALYSIS_TC_STAT_INPUT_TEMPLATE = + +SERIES_ANALYSIS_OUTPUT_DIR = {OUTPUT_BASE}/met_tool_wrapper/SeriesAnalysis +SERIES_ANALYSIS_OUTPUT_TEMPLATE = {init?fmt=%Y%m%d%H}_sa.nc + +SERIES_ANALYSIS_CLIMO_MEAN_INPUT_DIR = +SERIES_ANALYSIS_CLIMO_MEAN_INPUT_TEMPLATE = + +SERIES_ANALYSIS_CLIMO_STDEV_INPUT_DIR = +SERIES_ANALYSIS_CLIMO_STDEV_INPUT_TEMPLATE = + + +### +# Field Info +### + +MODEL = WRF +OBTYPE = MC_PCP + +FCST_VAR1_NAME = APCP +FCST_VAR1_LEVELS = A03 + +OBS_VAR1_NAME = APCP_03 +OBS_VAR1_LEVELS = "(*,*)" + +BOTH_VAR1_THRESH = gt12.7, gt25.4, gt50.8, gt76.2 -SERIES_ANALYSIS_CONFIG_FILE = {CONFIG_DIR}/SeriesAnalysisConfig_wrapped +### +# SeriesAnalysis +### -SERIES_ANALYSIS_STAT_LIST = TOTAL, RMSE, FBAR, OBAR +#LOG_SERIES_ANALYSIS_VERBOSITY = 2 + +SERIES_ANALYSIS_CONFIG_FILE = {PARM_BASE}/met_config/SeriesAnalysisConfig_wrapped -SERIES_ANALYSIS_DESC = +SERIES_ANALYSIS_IS_PAIRED = False -SERIES_ANALYSIS_CAT_THRESH = +#SERIES_ANALYSIS_DESC = -SERIES_ANALYSIS_VLD_THRESH = +#SERIES_ANALYSIS_CAT_THRESH = -SERIES_ANALYSIS_BLOCK_SIZE = +#SERIES_ANALYSIS_VLD_THRESH = -SERIES_ANALYSIS_CTS_LIST = +#SERIES_ANALYSIS_BLOCK_SIZE = -SERIES_ANALYSIS_REGRID_TO_GRID = -SERIES_ANALYSIS_REGRID_METHOD = -SERIES_ANALYSIS_REGRID_WIDTH = -SERIES_ANALYSIS_REGRID_VLD_THRESH = -SERIES_ANALYSIS_REGRID_SHAPE = +#SERIES_ANALYSIS_REGRID_TO_GRID = +#SERIES_ANALYSIS_REGRID_METHOD = +#SERIES_ANALYSIS_REGRID_WIDTH = +#SERIES_ANALYSIS_REGRID_VLD_THRESH = +#SERIES_ANALYSIS_REGRID_SHAPE = #SERIES_ANALYSIS_CLIMO_MEAN_FILE_NAME = #SERIES_ANALYSIS_CLIMO_MEAN_FIELD = @@ -74,51 +106,31 @@ SERIES_ANALYSIS_REGRID_SHAPE = #SERIES_ANALYSIS_HSS_EC_VALUE = -SERIES_ANALYSIS_RUNTIME_FREQ = RUN_ONCE_PER_INIT_OR_VALID +#FCST_SERIES_ANALYSIS_PROB_THRESH = -SERIES_ANALYSIS_RUN_ONCE_PER_STORM_ID = False +#SERIES_ANALYSIS_OUTPUT_STATS_FHO = +#SERIES_ANALYSIS_OUTPUT_STATS_CTC = +#SERIES_ANALYSIS_OUTPUT_STATS_CTS = +#SERIES_ANALYSIS_OUTPUT_STATS_MCTC = +#SERIES_ANALYSIS_OUTPUT_STATS_MCTS = -MODEL = WRF +SERIES_ANALYSIS_OUTPUT_STATS_CNT = TOTAL, RMSE, FBAR, OBAR -OBTYPE = MC_PCP - -#FCST_SERIES_ANALYSIS_PROB_THRESH = +#SERIES_ANALYSIS_OUTPUT_STATS_SL1L2 = +#SERIES_ANALYSIS_OUTPUT_STATS_SAL1L2 = +#SERIES_ANALYSIS_OUTPUT_STATS_PCT = +#SERIES_ANALYSIS_OUTPUT_STATS_PSTD = +#SERIES_ANALYSIS_OUTPUT_STATS_PJC = +#SERIES_ANALYSIS_OUTPUT_STATS_PRC = -FCST_VAR1_NAME = APCP - -FCST_VAR1_LEVELS = A03 -OBS_VAR1_NAME = APCP_03 - -OBS_VAR1_LEVELS = "(*,*)" - -BOTH_VAR1_THRESH = gt12.7, gt25.4, gt50.8, gt76.2 - -[dir] -CONFIG_DIR={PARM_BASE}/met_config - -FCST_SERIES_ANALYSIS_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst - -OBS_SERIES_ANALYSIS_INPUT_DIR = {INPUT_BASE}/met_test/new +### +# Plotting +### -SERIES_ANALYSIS_CLIMO_MEAN_INPUT_DIR = - -SERIES_ANALYSIS_CLIMO_STDEV_INPUT_DIR = - -SERIES_ANALYSIS_TC_STAT_INPUT_DIR = - -SERIES_ANALYSIS_OUTPUT_DIR = {OUTPUT_BASE}/met_tool_wrapper/SeriesAnalysis - -[filename_templates] - -FCST_SERIES_ANALYSIS_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/wrfprs_ruc13_{lead?fmt=%2H}.tm00_G212 - -OBS_SERIES_ANALYSIS_INPUT_TEMPLATE = ST2ml{valid?fmt=%Y%m%d%H}_A03h.nc - -SERIES_ANALYSIS_OUTPUT_TEMPLATE = {init?fmt=%Y%m%d%H}_sa.nc +SERIES_ANALYSIS_GENERATE_PLOTS = no -SERIES_ANALYSIS_CLIMO_MEAN_INPUT_TEMPLATE = +PLOT_DATA_PLANE_TITLE = -SERIES_ANALYSIS_CLIMO_STDEV_INPUT_TEMPLATE = +SERIES_ANALYSIS_GENERATE_ANIMATIONS = no -SERIES_ANALYSIS_TC_STAT_INPUT_TEMPLATE = diff --git a/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_extra_tropical.conf b/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_extra_tropical.conf index 62640e4ba8..255d41c784 100644 --- a/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_extra_tropical.conf +++ b/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_extra_tropical.conf @@ -1,37 +1,55 @@ -# -# CONFIGURATION -# [config] -# Looping by processes: run each 'task' in the PROCESS_LIST for all -# defined times then steps to the next 'task'. -LOOP_ORDER = processes - -# Configuration files -TC_PAIRS_CONFIG_FILE = {CONFIG_DIR}/TCPairsConfig_wrapped - PROCESS_LIST = TCPairs -# The init time begin and end times, increment +### +# Time Info +### + LOOP_BY = INIT INIT_TIME_FMT = %Y%m%d%H INIT_BEG = 2014121318 INIT_END = 2014121318 - -# This is the step-size. Increment in seconds from the begin time to the end -# time INIT_INCREMENT = 21600 ;; set to every 6 hours=21600 seconds TC_PAIRS_RUN_ONCE = True -# A list of times to include, in format YYYYMMDD_hh -TC_PAIRS_INIT_INCLUDE = -# A list of times to exclude, in format YYYYMMDD_hh +### +# File I/O +### + +TC_PAIRS_ADECK_INPUT_DIR = {INPUT_BASE}/met_test/new/track_data +TC_PAIRS_ADECK_TEMPLATE = {date?fmt=%Y%m}/a{basin?fmt=%s}q{date?fmt=%Y%m}*.gfso.{cyclone?fmt=%s} + +TC_PAIRS_BDECK_INPUT_DIR = {TC_PAIRS_ADECK_INPUT_DIR} +TC_PAIRS_BDECK_TEMPLATE = {date?fmt=%Y%m}/b{basin?fmt=%s}q{date?fmt=%Y%m}*.gfso.{cyclone?fmt=%s} + +TC_PAIRS_REFORMAT_DIR = {OUTPUT_BASE}/track_data_atcf + +TC_PAIRS_OUTPUT_DIR = {OUTPUT_BASE}/tc_pairs +TC_PAIRS_OUTPUT_TEMPLATE = {date?fmt=%Y%m}/{basin?fmt=%s}q{date?fmt=%Y%m%d%H}.gfso.{cyclone?fmt=%s} + + +TC_PAIRS_SKIP_IF_OUTPUT_EXISTS = yes +TC_PAIRS_SKIP_IF_REFORMAT_EXISTS = yes + +TC_PAIRS_READ_ALL_FILES = no +#TC_PAIRS_SKIP_LEAD_SEQ = False + +TC_PAIRS_REFORMAT_DECK = yes +TC_PAIRS_REFORMAT_TYPE = SBU + + +### +# TCPairs +### + +TC_PAIRS_CONFIG_FILE = {PARM_BASE}/met_config/TCPairsConfig_wrapped + +TC_PAIRS_INIT_INCLUDE = TC_PAIRS_INIT_EXCLUDE = -# Specify model init time window in format YYYYMM[DD[_hh]] -# Only tracks that fall within the initialization time window will be used TC_PAIRS_INIT_BEG = 2014121318 TC_PAIRS_INIT_END = 2014121418 @@ -40,55 +58,20 @@ TC_PAIRS_INIT_END = 2014121418 #TC_PAIRS_WRITE_VALID = -# Specify model valid time window in format YYYYMM[DD[_hh]] -# Only tracks that fall within the valid time window will be used TC_PAIRS_VALID_BEG = TC_PAIRS_VALID_END = -# Skip looping over forecast leads if a list is provided -#TC_PAIRS_SKIP_LEAD_SEQ = False - - -## -# -# MET TC-Pairs -# -## - -# -# Run MET tc_pairs by indicating the top-level directories for the A-deck -# and B-deck files. Set to 'yes' to run using top-level directories, 'no' -# if you want to run tc_pairs on files paired by the wrapper. -TC_PAIRS_READ_ALL_FILES = no - -# List of models to be used (white space or comma separated) eg: DSHP, LGEM, HWRF -# If no models are listed, then process all models in the input file(s). MODEL = #TC_PAIRS_DESC = -# List of storm ids of interest (space or comma separated) e.g.: AL112012, AL122012 -# If no storm ids are listed, then process all storm ids in the input file(s). TC_PAIRS_STORM_ID = - -# Basins (of origin/region). Indicate with space or comma-separated list of regions, eg. AL: for North Atlantic, -# WP: Western North Pacific, CP: Central North Pacific, SH: Southern Hemisphere, IO: North Indian Ocean, LS: Southern -# Hemisphere TC_PAIRS_BASIN = - -# Cyclone, a space or comma-separated list of cyclone numbers. If left empty, all cyclones will be used. TC_PAIRS_CYCLONE = - -# Storm name, a space or comma-separated list of storm names to evaluate. If left empty, all storms will be used. TC_PAIRS_STORM_NAME = -# DLAND file, the full path of the file that contains the gridded representation of the -# minimum distance from land. TC_PAIRS_DLAND_FILE = {MET_INSTALL_DIR}/share/met/tc_data/dland_global_tenth_degree.nc -TC_PAIRS_REFORMAT_DECK = yes -TC_PAIRS_REFORMAT_TYPE = SBU - TC_PAIRS_MISSING_VAL_TO_REPLACE = -99 TC_PAIRS_MISSING_VAL = -9999 @@ -97,43 +80,6 @@ TC_PAIRS_MISSING_VAL = -9999 #TC_PAIRS_CONSENSUS1_REQUIRED = #TC_PAIRS_CONSENSUS1_MIN_REQ = -# OVERWRITE OPTIONS -# Don't overwrite filter files if they already exist. -# Set to no if you do NOT want to override existing files -# Set to yes if you do want to override existing files - -# overwrite modified track data (non-ATCF to ATCF format) -TC_PAIRS_SKIP_IF_REFORMAT_EXISTS = yes - -# overwrite tc_pairs output -TC_PAIRS_SKIP_IF_OUTPUT_EXISTS = yes - -# FILENAME TEMPLATES -# -[filename_templates] -# Define the format of the filenames -TC_PAIRS_ADECK_TEMPLATE = {date?fmt=%Y%m}/a{basin?fmt=%s}q{date?fmt=%Y%m}*.gfso.{cyclone?fmt=%s} -TC_PAIRS_BDECK_TEMPLATE = {date?fmt=%Y%m}/b{basin?fmt=%s}q{date?fmt=%Y%m}*.gfso.{cyclone?fmt=%s} -TC_PAIRS_OUTPUT_TEMPLATE = {date?fmt=%Y%m}/{basin?fmt=%s}q{date?fmt=%Y%m%d%H}.gfso.{cyclone?fmt=%s} - -# -# DIRECTORIES -# -[dir] - -# MET config directory, location of configuration files used by MET applications -# CONFIG_DIR and the value it expands to is set as an environment variable -# and is used in the MET configuration file. -CONFIG_DIR={PARM_BASE}/met_config - -# track data, set to your data source -TC_PAIRS_ADECK_INPUT_DIR = {INPUT_BASE}/met_test/new/track_data -TC_PAIRS_BDECK_INPUT_DIR = {TC_PAIRS_ADECK_INPUT_DIR} -TC_PAIRS_REFORMAT_DIR = {OUTPUT_BASE}/track_data_atcf -TC_PAIRS_OUTPUT_DIR = {OUTPUT_BASE}/tc_pairs - - -# REGEX PATTERNS -# -[regex_pattern] +#TC_PAIRS_CHECK_DUP = +#TC_PAIRS_INTERP12 = diff --git a/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_tropical.conf b/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_tropical.conf index 81f9ed99c0..2812a0cc16 100644 --- a/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_tropical.conf +++ b/parm/use_cases/met_tool_wrapper/TCPairs/TCPairs_tropical.conf @@ -1,39 +1,54 @@ -# -# CONFIGURATION -# [config] -# Looping by times: steps through each 'task' in the PROCESS_LIST for each -# defined time, and repeats until all times have been evaluated. -LOOP_ORDER = times - -# Configuration files -TC_PAIRS_CONFIG_FILE = {PARM_BASE}/met_config/TCPairsConfig_wrapped - -# 'Tasks' to be run PROCESS_LIST = TCPairs -LOOP_BY = INIT +### +# Time Info +### -# The init time begin and end times, increment, and last init hour. +LOOP_BY = INIT INIT_TIME_FMT = %Y%m%d%H INIT_BEG = 2018083006 INIT_END = 2018083018 - -# This is the step-size. Increment in seconds from the begin time to the end time -# set to 6 hours = 21600 seconds INIT_INCREMENT = 21600 +#TC_PAIRS_SKIP_LEAD_SEQ = False + +LOOP_ORDER = times + TC_PAIRS_RUN_ONCE = False -# A list of times to include, in format YYYYMMDD_hh -TC_PAIRS_INIT_INCLUDE = -# A list of times to exclude, in format YYYYMMDD_hh +### +# File I/O +### + +TC_PAIRS_ADECK_INPUT_DIR = {INPUT_BASE}/met_test/new/hwrf/adeck +TC_PAIRS_ADECK_TEMPLATE = {model?fmt=%s}/*{cyclone?fmt=%s}l.{date?fmt=%Y%m%d%H}.trak.hwrf.atcfunix + +TC_PAIRS_BDECK_INPUT_DIR = {INPUT_BASE}/met_test/new/hwrf/bdeck +TC_PAIRS_BDECK_TEMPLATE = b{basin?fmt=%s}{cyclone?fmt=%s}{date?fmt=%Y}.dat + +TC_PAIRS_EDECK_INPUT_DIR = +TC_PAIRS_EDECK_TEMPLATE = + +TC_PAIRS_OUTPUT_DIR = {OUTPUT_BASE}/tc_pairs +TC_PAIRS_OUTPUT_TEMPLATE = tc_pairs_{basin?fmt=%s}{date?fmt=%Y%m%d%H}.dat + +TC_PAIRS_SKIP_IF_OUTPUT_EXISTS = no +TC_PAIRS_READ_ALL_FILES = no +TC_PAIRS_REFORMAT_DECK = no + + +### +# TCPairs +### + +TC_PAIRS_CONFIG_FILE = {PARM_BASE}/met_config/TCPairsConfig_wrapped + +TC_PAIRS_INIT_INCLUDE = TC_PAIRS_INIT_EXCLUDE = -# Specify model init time window in format YYYYMM[DD[_hh]] -# Only tracks that fall within the initialization time window will be used TC_PAIRS_INIT_BEG = TC_PAIRS_INIT_END = @@ -42,58 +57,18 @@ TC_PAIRS_INIT_END = #TC_PAIRS_WRITE_VALID = -# Specify model valid time window in format YYYYMM[DD[_hh]] -# Only tracks that fall within the valid time window will be used TC_PAIRS_VALID_BEG = TC_PAIRS_VALID_END = -# -# Run MET tc_pairs by indicating the top-level directories for the A-deck and B-deck files. Set to 'yes' to -# run using top-level directories, 'no' if you want to run tc_pairs on files paired by the wrapper. -TC_PAIRS_READ_ALL_FILES = no - -# set to true or yes to reformat track data into ATCF format expected by tc_pairs -TC_PAIRS_REFORMAT_DECK = no - -# OVERWRITE OPTIONS -# Don't overwrite filter files if they already exist. -# Set to yes if you do NOT want to override existing files -# Set to no if you do want to override existing files -TC_PAIRS_SKIP_IF_OUTPUT_EXISTS = no - -# Skip looping over forecast leads if a list is provided -#TC_PAIRS_SKIP_LEAD_SEQ = False - - -# -# MET TC-Pairs -# -# List of models to be used (white space or comma separated) eg: DSHP, LGEM, HWRF -# If no models are listed, then process all models in the input file(s). MODEL = MYNN, H19C, H19M, CTRL, MYGF #TC_PAIRS_DESC = -# List of storm ids of interest (space or comma separated) e.g.: AL112012, AL122012 -# If no storm ids are listed, then process all storm ids in the input file(s). -#TC_PAIRS_STORM_ID = ML2092014 #TC_PAIRS_STORM_ID = al062018, al092018, al132018, al142018 - -# Basins (of origin/region). Indicate with space or comma-separated list of regions, eg. AL: for North Atlantic, -# WP: Western North Pacific, CP: Central North Pacific, SH: Southern Hemisphere, IO: North Indian Ocean, LS: Southern -# Hemisphere #TC_PAIRS_BASIN = AL -TC_PAIRS_BASIN = - -# Cyclone, a space or comma-separated list of cyclone numbers. If left empty, all cyclones will be used. TC_PAIRS_CYCLONE = 06 -#TC_PAIRS_CYCLONE = - -# Storm name, a space or comma-separated list of storm names to evaluate. If left empty, all storms will be used. TC_PAIRS_STORM_NAME = -# DLAND file, the full path of the file that contains the gridded representation of the -# minimum distance from land. TC_PAIRS_DLAND_FILE = MET_BASE/tc_data/dland_global_tenth_degree.nc #TC_PAIRS_CONSENSUS1_NAME = @@ -101,20 +76,6 @@ TC_PAIRS_DLAND_FILE = MET_BASE/tc_data/dland_global_tenth_degree.nc #TC_PAIRS_CONSENSUS1_REQUIRED = #TC_PAIRS_CONSENSUS1_MIN_REQ = -# -# DIRECTORIES -# -[dir] -# Location of input track data directory -# for ADECK and BDECK data -TC_PAIRS_ADECK_INPUT_DIR = {INPUT_BASE}/met_test/new/hwrf/adeck -TC_PAIRS_EDECK_INPUT_DIR = -TC_PAIRS_BDECK_INPUT_DIR = {INPUT_BASE}/met_test/new/hwrf/bdeck +#TC_PAIRS_CHECK_DUP = -TC_PAIRS_OUTPUT_DIR = {OUTPUT_BASE}/tc_pairs - -[filename_templates] -TC_PAIRS_ADECK_TEMPLATE = {model?fmt=%s}/*{cyclone?fmt=%s}l.{date?fmt=%Y%m%d%H}.trak.hwrf.atcfunix -TC_PAIRS_EDECK_TEMPLATE = -TC_PAIRS_BDECK_TEMPLATE = b{basin?fmt=%s}{cyclone?fmt=%s}{date?fmt=%Y}.dat -TC_PAIRS_OUTPUT_TEMPLATE = tc_pairs_{basin?fmt=%s}{date?fmt=%Y%m%d%H}.dat +#TC_PAIRS_INTERP12 = From 7e95915f142ce658333cf268119e43e1082c594d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 30 Dec 2021 10:16:41 -0700 Subject: [PATCH 23/43] removed incorrect search keyword --- .../Point2Grid_obsLSR_ObsOnly_PracticallyPerfect.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect.py b/docs/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect.py index 52cf171f73..cb1c964e5b 100644 --- a/docs/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect.py +++ b/docs/use_cases/model_applications/convection_allowing_models/Point2Grid_obsLSR_ObsOnly_PracticallyPerfect.py @@ -145,7 +145,6 @@ # * ASCII2NCToolUseCase # * Point2GridUseCase # * RegridDataPlaneToolUseCase -# * PyEmbedIngestToolUseCase # * RegriddingInToolUseCase # * NetCDFFileUseCase # * PythonEmbeddingFileUseCase From e5f53b13fe7b1d148eae5bf32138ca6cd4fc98fe Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 30 Dec 2021 15:38:12 -0700 Subject: [PATCH 24/43] added workflow_dispatch event so workflow can be triggered by an external repository such as MET to test to ensure that changes from that repo will break anything in METplus --- .github/jobs/set_job_controls.sh | 9 +++++++++ .github/workflows/testing.yml | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/jobs/set_job_controls.sh b/.github/jobs/set_job_controls.sh index 0f175711d1..b8e05e27da 100755 --- a/.github/jobs/set_job_controls.sh +++ b/.github/jobs/set_job_controls.sh @@ -13,6 +13,7 @@ run_use_cases=true run_save_truth_data=false run_all_use_cases=false run_diff=false +external_trigger=false # run all use cases and diff logic for pull request if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then @@ -25,6 +26,12 @@ if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then run_all_use_cases=true run_diff=true fi +# run all use cases and diff logic for external workflow trigger +elif [ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]; then + run_use_cases=true + run_all_use_cases=true + run_diff=true + external_trigger=true # run all use cases and save truth data if -ref branch and not PR elif [ "${GITHUB_REF: -4}" == -ref ]; then run_use_cases=true @@ -98,6 +105,7 @@ echo run_use_cases=${run_use_cases} >> job_control_status echo run_save_truth_data=${run_save_truth_data} >> job_control_status echo run_all_use_cases=${run_all_use_cases} >> job_control_status echo run_diff=${run_diff} >> job_control_status +echo external_trigger=${external_trigger} >> job_control_status echo Job Control Settings: cat job_control_status @@ -105,6 +113,7 @@ echo ::set-output name=run_get_image::$run_get_image echo ::set-output name=run_get_input_data::$run_get_input_data echo ::set-output name=run_diff::$run_diff echo ::set-output name=run_save_truth_data::$run_save_truth_data +echo ::set-output name=external_trigger::$external_trigger # get use cases to run .github/jobs/get_use_cases_to_run.sh $run_use_cases $run_all_use_cases $run_unit_tests diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 23b87420a8..5cc434d064 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,6 +13,13 @@ on: types: [opened, reopened, synchronize] paths-ignore: - docs/** + workflow_dispatch: + inputs: + repo_name: + description: 'Repository that triggered workflow' + required: true + docker_tag: + description: 'DockerHub tag to use (for MET)' jobs: job_control: @@ -25,6 +32,7 @@ jobs: run_get_input_data: ${{ steps.job_status.outputs.run_get_input_data }} run_diff: ${{ steps.job_status.outputs.run_diff }} run_save_truth_data: ${{ steps.job_status.outputs.run_save_truth_data }} + external_trigger: ${{ steps.job_status.outputs.external_trigger }} steps: - uses: actions/checkout@v2 - name: Print GitHub values for reference From 5673771634ad6a7f9b50ac0e2619326df27f803f Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 5 Jan 2022 13:39:51 -0700 Subject: [PATCH 25/43] added another input argument for workflow_dispatch event --- .github/workflows/testing.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5cc434d064..4cb9d20dc7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -20,6 +20,9 @@ on: required: true docker_tag: description: 'DockerHub tag to use (for MET)' + actor: + description: 'User that triggered the event' + required: true jobs: job_control: From b25d72881d8fe891a356ae2c34b9c40d5d26b83b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 5 Jan 2022 15:04:23 -0700 Subject: [PATCH 26/43] added job with name that shows the event name or the repository name if triggered by an external repository such as MET --- .github/workflows/testing.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4cb9d20dc7..ece34146a6 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -25,6 +25,15 @@ on: required: true jobs: + event_info: + name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repo_name }}" + runs-on: ubuntu-latest + steps: + - name: Print GitHub values for reference + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + job_control: name: Determine which jobs to run runs-on: ubuntu-latest @@ -38,10 +47,6 @@ jobs: external_trigger: ${{ steps.job_status.outputs.external_trigger }} steps: - uses: actions/checkout@v2 - - name: Print GitHub values for reference - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - run: echo "$GITHUB_CONTEXT" - name: Set job controls id: job_status run: .github/jobs/set_job_controls.sh From ab508d17bb34abd9d7808afa17621dfda64c1a26 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 5 Jan 2022 15:20:13 -0700 Subject: [PATCH 27/43] GHA: add username that triggered external event to event info job name --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ece34146a6..3552028e43 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -26,7 +26,7 @@ on: jobs: event_info: - name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repo_name }}" + name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repo_name }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.actor }}" runs-on: ubuntu-latest steps: - name: Print GitHub values for reference From 35f20039c3aebce358da8c6006ea45ff2ebd5a35 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 5 Jan 2022 15:46:44 -0700 Subject: [PATCH 28/43] added required input argument for external trigger that contains the commit hash of the push event that triggered in the other repo --- .github/workflows/testing.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 3552028e43..b96f5d3ead 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -23,6 +23,9 @@ on: actor: description: 'User that triggered the event' required: true + commit: + description: 'Commit hash that triggered the event' + required: true jobs: event_info: From 1f9778521fb71ad07f9febc6f61cd8a8c41a4dc2 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 5 Jan 2022 16:45:40 -0700 Subject: [PATCH 29/43] change event info to show commit hash instead of username that merged the PR --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b96f5d3ead..44e25ad2d8 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -29,7 +29,7 @@ on: jobs: event_info: - name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repo_name }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.actor }}" + name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repo_name }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.commit }}" runs-on: ubuntu-latest steps: - name: Print GitHub values for reference From e4c2f3bb11368182b4f79aee7ed0b1e1b710e102 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 5 Jan 2022 16:54:05 -0700 Subject: [PATCH 30/43] changed input names to match names of event in repository that triggered workflow --- .github/workflows/testing.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 44e25ad2d8..c6b7f7c5f9 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -15,21 +15,20 @@ on: - docs/** workflow_dispatch: inputs: - repo_name: + repository: description: 'Repository that triggered workflow' required: true - docker_tag: - description: 'DockerHub tag to use (for MET)' - actor: - description: 'User that triggered the event' - required: true - commit: + sha: description: 'Commit hash that triggered the event' required: true + ref: + description: 'Branch that triggered event' + actor: + description: 'User that triggered the event' jobs: event_info: - name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repo_name }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.commit }}" + name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repository }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.sha }}" runs-on: ubuntu-latest steps: - name: Print GitHub values for reference From e2a44bccb70a2af3c7029b887d8b82fcc1cdd7ee Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Tue, 11 Jan 2022 10:21:35 -0700 Subject: [PATCH 31/43] feature 1320 OMP_NUM_THREADS (#1338) --- docs/Users_Guide/glossary.rst | 10 ++++++ docs/Users_Guide/systemconfiguration.rst | 7 ++++ metplus/util/met_util.py | 43 +++++++++++++++++------- metplus/wrappers/command_builder.py | 6 ++++ parm/metplus_config/defaults.conf | 5 +++ 5 files changed, 58 insertions(+), 13 deletions(-) diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index 5183c899be..5b24d0b973 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -8693,3 +8693,13 @@ METplus Configuration Glossary :term:`VALID_TIME_FMT` or they will be skipped. | *Used by:* All + + OMP_NUM_THREADS + Sets environment variable of the same name that determines the number + of threads to use in the MET executables. Defaults to 1 thread. + If the environment variable of the same name is already set in the + user's environment, then that value will be used instead of the value + set in the METplus configuration. A warning will be output if this is the + case and the values differ between them. + + | *Used by:* All diff --git a/docs/Users_Guide/systemconfiguration.rst b/docs/Users_Guide/systemconfiguration.rst index 1bcb65ad82..3f97b5be84 100644 --- a/docs/Users_Guide/systemconfiguration.rst +++ b/docs/Users_Guide/systemconfiguration.rst @@ -205,6 +205,13 @@ By default this is a directory called **stage** inside the This value is rarely changed, but it can be if desired. +OMP_NUM_THREADS +^^^^^^^^^^^^^^^ + +If the MET executables were installed with threading support, then the number +of threads used by the tools can be configured with this variable. See +the glossary entry for :term:`OMP_NUM_THREADS` for more information. + CONVERT ^^^^^^^ diff --git a/metplus/util/met_util.py b/metplus/util/met_util.py index aed1e225b1..0949726a9e 100644 --- a/metplus/util/met_util.py +++ b/metplus/util/met_util.py @@ -88,6 +88,11 @@ def pre_run_setup(config_inputs): # handle dir to write temporary files handle_tmp_dir(config) + # handle OMP_NUM_THREADS environment variable + handle_env_var_config(config, + env_var_name='OMP_NUM_THREADS', + config_name='OMP_NUM_THREADS') + config.env = os.environ.copy() return config @@ -230,19 +235,7 @@ def handle_tmp_dir(config): get config temp dir using getdir_nocheck to bypass check for /path/to this is done so the user can set env MET_TMP_DIR instead of config TMP_DIR and config TMP_DIR will be set automatically""" - met_tmp_dir = os.environ.get('MET_TMP_DIR', '') - conf_tmp_dir = config.getdir_nocheck('TMP_DIR', '') - - # if env MET_TMP_DIR is set - if met_tmp_dir: - # override config TMP_DIR to env MET_TMP_DIR value - config.set('config', 'TMP_DIR', met_tmp_dir) - - # if config TMP_DIR differed from env MET_TMP_DIR, warn - if conf_tmp_dir != met_tmp_dir: - msg = 'TMP_DIR in config will be overridden by the ' +\ - 'environment variable MET_TMP_DIR ({})'.format(met_tmp_dir) - config.logger.warning(msg) + handle_env_var_config(config, 'MET_TMP_DIR', 'TMP_DIR') # create temp dir if it doesn't exist already # this will fail if TMP_DIR is not set correctly and @@ -251,6 +244,30 @@ def handle_tmp_dir(config): if not os.path.exists(tmp_dir): os.makedirs(tmp_dir) +def handle_env_var_config(config, env_var_name, config_name): + """! If environment variable is set, use that value + for the config variable and warn if the previous config value differs + + @param config METplusConfig object to read + @param env_var_name name of environment variable to read + @param config_name name of METplus config variable to check + """ + env_var_value = os.environ.get(env_var_name, '') + config_value = config.getdir_nocheck(config_name, '') + + # do nothing if environment variable is not set + if not env_var_value: + return + + # override config config variable to environment variable value + config.set('config', config_name, env_var_value) + + # if config config value differed from environment variable value, warn + if config_value != env_var_value: + config.logger.warning(f'Config variable {config_name} ({config_value}) ' + 'will be overridden by the environment variable ' + f'{env_var_name} ({env_var_value})') + def get_skip_times(config, wrapper_name=None): """! Read SKIP_TIMES config variable and populate dictionary of times that should be skipped. SKIP_TIMES should be in the format: "%m:begin_end_incr(3,11,1)", "%d:30,31", "%Y%m%d:20201031" diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 9b4ea34922..237f9d79d6 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -70,6 +70,7 @@ def __init__(self, config, instance=None, config_overrides=None): # list of environment variables to set before running command self.env_var_keys = [ 'MET_TMP_DIR', + 'OMP_NUM_THREADS', ] if hasattr(self, 'WRAPPER_ENV_VAR_KEYS'): self.env_var_keys.extend(self.WRAPPER_ENV_VAR_KEYS) @@ -117,6 +118,11 @@ def __init__(self, config, instance=None, config_overrides=None): # where the MET tools write temporary files self.env_var_dict['MET_TMP_DIR'] = self.config.getdir('TMP_DIR') + # set OMP_NUM_THREADS environment variable + self.env_var_dict['OMP_NUM_THREADS'] = ( + self.config.getstr('config', 'OMP_NUM_THREADS') + ) + self.check_for_externals() self.cmdrunner = CommandRunner( diff --git a/parm/metplus_config/defaults.conf b/parm/metplus_config/defaults.conf index 5ae3cc9196..e4636ba47a 100644 --- a/parm/metplus_config/defaults.conf +++ b/parm/metplus_config/defaults.conf @@ -57,6 +57,10 @@ GFDL_TRACKER_EXEC = /path/to/standalone_gfdl-vortextracker_v3.9a/trk_exec ############################################################################### # Runtime Configuration # +# * OMP_NUM_THREADS sets an environment variable of the same name that # +# determines the number of threads to use in the MET executables. If the # +# environment variable is already set in the user's environment, then # +# that value will be used instead of the value set in this file. # ############################################################################### @@ -64,6 +68,7 @@ LOOP_ORDER = processes PROCESS_LIST = Usage +OMP_NUM_THREADS = 1 ############################################################################### # Log File Information (Where to write logs files) # From e65949be4cb06ed2ebbcc48cfe10cd74e82a91ef Mon Sep 17 00:00:00 2001 From: j-opatz <59586397+j-opatz@users.noreply.github.com> Date: Tue, 11 Jan 2022 11:57:03 -0700 Subject: [PATCH 32/43] Feature 1183 memory documentation (#1340) Co-authored-by: George McCabe <23407799+georgemccabe@users.noreply.github.com> --- docs/Contributors_Guide/add_use_case.rst | 66 +++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/Contributors_Guide/add_use_case.rst b/docs/Contributors_Guide/add_use_case.rst index a074fa32f2..eb4e07ad2c 100644 --- a/docs/Contributors_Guide/add_use_case.rst +++ b/docs/Contributors_Guide/add_use_case.rst @@ -144,8 +144,20 @@ Use Case Rules - The use case should be run by someone other than the author to ensure that it runs smoothly outside of the development environment set up by the author. -.. _use_case_documentation: +.. _memory-intense-use-cases: + +Use Cases That Exceed Github Actions Memory Limit +------------------------------------------------- + +Below is a list of use cases in the repository that cannot be run in Github Actions +due to their excessive memory usage. They have been tested and cleared by reviewers +of any other issues and can be used by METplus users in the same manner as all +other use cases. +- model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst + +.. _use_case_documentation: + Document New Use Case --------------------- @@ -1024,6 +1036,24 @@ with "Use Case Tests." Click on the job and search for the use case config filename in the log output by using the search box on the top right of the log output. +If the use case fails in GitHub Actions but runs successfully in the user's environment, +potential reasons include: + +- Errors providing input data (see :ref:`use_case_input_data`) +- Using hard-coded paths from the user's machine +- Referencing variables set in the user's configuration file or local environment +- Memory usuage of the use case exceeds the available memory in hte Github Actions environment + +Github Actions has `limited memory `_ +available and will cause the use case to fail when exceeded. A failure caused by exceeding +the memory allocation in a Python Embedding script may result in an unclear error message. +If you suspect that this is the case, consider utilizing a Python memory profiler to check the +Python script's memory usage. If your use case exceeds the limit, try to pare +down the data held in memory and use less memory intensive Python routines. + +If memory mitigation cannot move the use case’s memory usage below the Github Actions limit, +see :ref:`exceeded-Github-Actions` for next steps. + Verify that the use case ran in a reasonable amount of time ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -1039,6 +1069,40 @@ run the set of use cases is now above 20 minutes or so, consider creating a new job for the new use case. See the :ref:`subset_category` section and the multiple medium_range jobs for an example. + +.. _exceeded-Github-Actions: + +Use Cases That Exceed Memory Allocations of Github Actions +---------------------------------------------------------- + +If a use case utilizing Python embedding does not run successfully in +Github Actions due to exceeding the memory limit and memory mitigation +steps were unsuccessful in lowering memory usage, please take the following steps. + +- Document the Github Actions failure in the Github use case issue. + Utilize a Python memory profiler to identify as specifically as possible + where the script exceeds the memory limit. +- Add the use case to the :ref:`memory-intense-use-cases` list. +- In the internal_tests/use_cases/all_use_cases.txt file, ensure that the + use case is listed as the lowest-listed use case in its respective category. + Change the number in front of the new use case to an 'X', preceeded + by the ‘#’ character:: + + #X::GridStat_fcstRTOFS_obsGHRSST_climWOA_sst::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst.conf, model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst/ci_overrides.conf:: icecover_env, py_embed + +- In the **.github/parm/use_case_groups.json** file, remove the entry that + was added during the :ref:`add_new_category_to_test_runs` + for the new use case. This will stop the use case from running on a pull request. +- Push these two updated files to your branch in Github and confirm that it + now compiles successfully. +- During the :ref:`create-a-pull-request` creation, inform the reviewer of + the Github Actions failure. The reviewer should confirm the use case is + successful when run manually, that the memory profiler output confirms that + the Python embedding script exceeds the Github Actions limit, and that + there are no other Github Actions compiling errors. + +.. _create-a-pull-request: + Create a Pull Request ===================== From 8f8b9f94a3e9ee73a54b18770072e9880a76662c Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Wed, 12 Jan 2022 11:55:40 -0700 Subject: [PATCH 33/43] add email address of user who triggered push event to job name --- .github/workflows/testing.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c6b7f7c5f9..79c0b51e32 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -25,10 +25,12 @@ on: description: 'Branch that triggered event' actor: description: 'User that triggered the event' + pusher_email: + description: 'Email address of user who triggered push event' jobs: event_info: - name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repository }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.sha }}" + name: "Trigger: ${{ github.event_name != 'workflow_dispatch' && github.event_name || github.event.inputs.repository }} ${{ github.event_name != 'workflow_dispatch' && 'local' || github.event.inputs.pusher_email }} ${{ github.event_name != 'workflow_dispatch' && 'event' || github.event.inputs.sha }}" runs-on: ubuntu-latest steps: - name: Print GitHub values for reference From a13835586e83a254b04c2026c1e7a4c5db1f7166 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 13 Jan 2022 10:24:40 -0700 Subject: [PATCH 34/43] Feature 1166 series analysis field info (#1353) --- docs/Users_Guide/glossary.rst | 20 ++ docs/Users_Guide/wrappers.rst | 30 +++ .../series_analysis/test_series_analysis.py | 27 ++- metplus/util/string_template_substitution.py | 4 +- metplus/wrappers/command_builder.py | 6 +- metplus/wrappers/series_analysis_wrapper.py | 193 ++++++++++++------ parm/met_config/SeriesAnalysisConfig_wrapped | 9 + .../SeriesAnalysis/SeriesAnalysis.conf | 5 + 8 files changed, 213 insertions(+), 81 deletions(-) diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index 5b24d0b973..f3e3f1ec54 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -8694,6 +8694,26 @@ METplus Configuration Glossary | *Used by:* All + FCST_SERIES_ANALYSIS_CAT_THRESH + Specify the value for 'fcst.cat_thresh' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + OBS_SERIES_ANALYSIS_CAT_THRESH + Specify the value for 'obs.cat_thresh' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_CLIMO_MEAN_FILE_TYPE + Specify the value for 'climo_mean.file_type' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + + SERIES_ANALYSIS_CLIMO_STDEV_FILE_TYPE + Specify the value for 'climo_stdev.file_type' in the MET configuration file for SeriesAnalysis. + + | *Used by:* SeriesAnalysis + OMP_NUM_THREADS Sets environment variable of the same name that determines the number of threads to use in the MET executables. Defaults to 1 thread. diff --git a/docs/Users_Guide/wrappers.rst b/docs/Users_Guide/wrappers.rst index 9cd7af499e..e06bee76fa 100644 --- a/docs/Users_Guide/wrappers.rst +++ b/docs/Users_Guide/wrappers.rst @@ -5871,6 +5871,7 @@ METplus Configuration | :term:`SERIES_ANALYSIS_CLIMO_MEAN_MATCH_MONTH` | :term:`SERIES_ANALYSIS_CLIMO_MEAN_DAY_INTERVAL` | :term:`SERIES_ANALYSIS_CLIMO_MEAN_HOUR_INTERVAL` +| :term:`SERIES_ANALYSIS_CLIMO_MEAN_FILE_TYPE` | :term:`SERIES_ANALYSIS_CLIMO_STDEV_FILE_NAME` | :term:`SERIES_ANALYSIS_CLIMO_STDEV_FIELD` | :term:`SERIES_ANALYSIS_CLIMO_STDEV_REGRID_METHOD` @@ -5881,6 +5882,7 @@ METplus Configuration | :term:`SERIES_ANALYSIS_CLIMO_STDEV_MATCH_MONTH` | :term:`SERIES_ANALYSIS_CLIMO_STDEV_DAY_INTERVAL` | :term:`SERIES_ANALYSIS_CLIMO_STDEV_HOUR_INTERVAL` +| :term:`SERIES_ANALYSIS_CLIMO_STDEV_FILE_TYPE` | :term:`SERIES_ANALYSIS_HSS_EC_VALUE` | :term:`SERIES_ANALYSIS_OUTPUT_STATS_FHO` | :term:`SERIES_ANALYSIS_OUTPUT_STATS_CTC` @@ -5894,6 +5896,8 @@ METplus Configuration | :term:`SERIES_ANALYSIS_OUTPUT_STATS_PSTD` | :term:`SERIES_ANALYSIS_OUTPUT_STATS_PJC` | :term:`SERIES_ANALYSIS_OUTPUT_STATS_PRC` +| :term:`FCST_SERIES_ANALYSIS_CAT_THRESH` +| :term:`OBS_SERIES_ANALYSIS_CAT_THRESH` | .. warning:: **DEPRECATED:** @@ -6076,6 +6080,8 @@ see :ref:`How METplus controls MET config file settings`. - climo_mean.day_interval * - :term:`SERIES_ANALYSIS_CLIMO_MEAN_HOUR_INTERVAL` - climo_mean.hour_interval + * - :term:`SERIES_ANALYSIS_CLIMO_MEAN_FILE_TYPE` + - climo_mean.file_type **${METPLUS_CLIMO_STDEV_DICT}** @@ -6105,6 +6111,8 @@ see :ref:`How METplus controls MET config file settings`. - climo_stdev.day_interval * - :term:`SERIES_ANALYSIS_CLIMO_STDEV_HOUR_INTERVAL` - climo_stdev.hour_interval + * - :term:`SERIES_ANALYSIS_CLIMO_STDEV_FILE_TYPE` + - climo_stdev.file_type **${METPLUS_BLOCK_SIZE}** @@ -6184,6 +6192,28 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`SERIES_ANALYSIS_OUTPUT_STATS_PRC` - output_stats.prc +**${METPLUS_FCST_CAT_THRESH}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`FCST_SERIES_ANALYSIS_CAT_THRESH` + - fcst.cat_thresh + +**${METPLUS_OBS_CAT_THRESH}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`OBS_SERIES_ANALYSIS_CAT_THRESH` + - obs.cat_thresh + SeriesByInit ============ diff --git a/internal_tests/pytests/series_analysis/test_series_analysis.py b/internal_tests/pytests/series_analysis/test_series_analysis.py index 5d21eb0b39..f8ec19e35f 100644 --- a/internal_tests/pytests/series_analysis/test_series_analysis.py +++ b/internal_tests/pytests/series_analysis/test_series_analysis.py @@ -270,6 +270,11 @@ def set_minimum_config_settings(config): 'pstd = ["RMSE10", "FBAR", "OBAR"];' 'pjc = ["RMSE11", "FBAR", "OBAR"];' 'prc = ["RMSE12", "FBAR", "OBAR"];}')}), + ({'SERIES_ANALYSIS_FCST_CAT_THRESH': '>=0.0, >=0.3, >=1.0', }, + {'METPLUS_FCST_CAT_THRESH': 'cat_thresh = [>=0.0, >=0.3, >=1.0];'}), + + ({'SERIES_ANALYSIS_OBS_CAT_THRESH': '<=CDP33', }, + {'METPLUS_OBS_CAT_THRESH': 'cat_thresh = [<=CDP33];'}), ] ) @@ -738,29 +743,23 @@ def test_create_ascii_storm_files_list(metplus_config, config_overrides, leads = lead_group[1] else: leads = None - fcst_list_file = wrapper.get_ascii_filename('FCST', storm_id, leads) + fcst_list_file = wrapper._get_ascii_filename('FCST', storm_id, leads) fcst_file_path = os.path.join(output_dir, output_prefix, fcst_list_file) if os.path.exists(fcst_file_path): os.remove(fcst_file_path) - obs_list_file = wrapper.get_ascii_filename('OBS', storm_id, leads) + obs_list_file = wrapper._get_ascii_filename('OBS', storm_id, leads) obs_file_path = os.path.join(output_dir, output_prefix, obs_list_file) if os.path.exists(obs_file_path): os.remove(obs_file_path) - # perform string substitution on var list - wrapper.c_dict['VAR_LIST'] = ( - sub_var_list(wrapper.c_dict['VAR_LIST_TEMP'], - time_info) - ) - - fcst_path, obs_path = wrapper.create_ascii_storm_files_list(time_info, - storm_id, - lead_group) + fcst_path, obs_path = wrapper._create_ascii_storm_files_list(time_info, + storm_id, + lead_group) assert(fcst_path == fcst_file_path and obs_path == obs_file_path) with open(fcst_file_path, 'r') as file_handle: @@ -813,7 +812,7 @@ def test_get_ascii_filename(metplus_config, storm_id, leads, expected_result): wrapper = series_analysis_wrapper(metplus_config) for data_type in ['FCST', 'OBS']: - actual_result = wrapper.get_ascii_filename(data_type, + actual_result = wrapper._get_ascii_filename(data_type, storm_id, leads) assert(actual_result == f"{data_type}{expected_result}") @@ -822,7 +821,7 @@ def test_get_ascii_filename(metplus_config, storm_id, leads, return lead_seconds = [ti_get_seconds_from_lead(item) for item in leads] - actual_result = wrapper.get_ascii_filename(data_type, + actual_result = wrapper._get_ascii_filename(data_type, storm_id, lead_seconds) assert(actual_result == f"{data_type}{expected_result}") @@ -865,7 +864,7 @@ def test_get_netcdf_min_max(metplus_config): 'tc_data', 'basin_global_tenth_degree.nc') variable_name = 'basin' - min, max = wrapper.get_netcdf_min_max(filepath, variable_name) + min, max = wrapper._get_netcdf_min_max(filepath, variable_name) assert(min == expected_min) assert(max == expected_max) diff --git a/metplus/util/string_template_substitution.py b/metplus/util/string_template_substitution.py index e4085ebc50..7aeb709942 100644 --- a/metplus/util/string_template_substitution.py +++ b/metplus/util/string_template_substitution.py @@ -773,8 +773,8 @@ def add_date_matches_to_output_dict(match_dict, output_dict, time_type, valid_sh time_values = { 'Y': -1, 'y': -1, - 'm': -1, - 'd': -1, + 'm': 1, + 'd': 1, 'j': -1, 'H': 0, 'M': 0, diff --git a/metplus/wrappers/command_builder.py b/metplus/wrappers/command_builder.py index 237f9d79d6..4eccbb92d3 100755 --- a/metplus/wrappers/command_builder.py +++ b/metplus/wrappers/command_builder.py @@ -993,12 +993,15 @@ def find_and_check_output_file(self, time_info=None, output_path = do_string_sub(output_path, **time_info) + # replace wildcard character * with all + output_path.replace('*', 'all') + skip_if_output_exists = self.c_dict.get('SKIP_IF_OUTPUT_EXISTS', False) # get directory that the output file will exist if is_directory: parent_dir = output_path - if time_info: + if time_info and time_info['valid'] != '*': valid_format = time_info['valid'].strftime('%Y%m%d_%H%M%S') else: valid_format = '' @@ -1523,6 +1526,7 @@ def handle_climo_dict(self): 'match_month': ('bool', 'uppercase'), 'day_interval': 'int', 'hour_interval': 'int', + 'file_type': ('string', 'remove_quotes'), } for climo_type in self.climo_types: dict_name = f'climo_{climo_type.lower()}' diff --git a/metplus/wrappers/series_analysis_wrapper.py b/metplus/wrappers/series_analysis_wrapper.py index 8fdd625079..578dad2736 100755 --- a/metplus/wrappers/series_analysis_wrapper.py +++ b/metplus/wrappers/series_analysis_wrapper.py @@ -24,7 +24,7 @@ from ..util import getlist from ..util import met_util as util -from ..util import do_string_sub, parse_template +from ..util import do_string_sub, parse_template, get_tags from ..util import get_lead_sequence, get_lead_sequence_groups from ..util import ti_get_hours_from_lead, ti_get_seconds_from_lead from ..util import ti_get_lead_string @@ -53,6 +53,8 @@ class SeriesAnalysisWrapper(RuntimeFreqWrapper): 'METPLUS_VLD_THRESH', 'METPLUS_OUTPUT_STATS_DICT', 'METPLUS_HSS_EC_VALUE', + 'METPLUS_FCST_CAT_THRESH', + 'METPLUS_OBS_CAT_THRESH', ] # handle deprecated env vars used pre v4.0.0 @@ -89,7 +91,7 @@ def __init__(self, config, instance=None, config_overrides=None): config_overrides=config_overrides) if self.c_dict['GENERATE_PLOTS']: - self.plot_data_plane = self.plot_data_plane_init() + self.plot_data_plane = self._plot_data_plane_init() if WRAPPER_CANNOT_RUN: self.log_error("There was a problem importing modules: " @@ -159,10 +161,6 @@ def create_c_dict(self): output_stats_dict[key] = value self.add_met_config_dict('output_stats', output_stats_dict) - if not c_dict['STAT_LIST']: - self.log_error("Must set SERIES_ANALYSIS_STAT_LIST to run.") - - # set legacy stat list to set output_stats.cnt in MET config file self.add_met_config(name='cnt', data_type='list', @@ -201,6 +199,42 @@ def create_c_dict(self): # initialize list path to None for each type c_dict[f'{data_type}_LIST_PATH'] = None + # read and set file type env var for FCST and OBS + if data_type == 'BOTH': + continue + + self.add_met_config( + name='file_type', + data_type='string', + env_var_name=f'{data_type}_FILE_TYPE', + metplus_configs=[f'{data_type}_SERIES_ANALYSIS_FILE_TYPE', + f'SERIES_ANALYSIS_{data_type}_FILE_TYPE', + f'{data_type}_FILE_TYPE', + f'{data_type}_SERIES_ANALYSIS_INPUT_DATATYPE', + 'SERIES_ANALYSIS_FILE_TYPE'], + extra_args={'remove_quotes': True, + 'uppercase': True}) + + self.add_met_config( + name='cat_thresh', + data_type='list', + env_var_name=f'METPLUS_{data_type}_CAT_THRESH', + metplus_configs=[f'{data_type}_SERIES_ANALYSIS_CAT_THRESH', + f'SERIES_ANALYSIS_{data_type}_CAT_THRESH', + f'{data_type}_CAT_THRESH'], + extra_args={'remove_quotes': True} + ) + + c_dict[f'{data_type}_IS_PROB'] = ( + self.config.getbool('config', f'{data_type}_IS_PROB', False) + ) + if c_dict[f'{data_type}_IS_PROB']: + c_dict[f'{data_type}_PROB_IN_GRIB_PDS'] = ( + self.config.getbool('config', + f'{data_type}_PROB_IN_GRIB_PDS', + False) + ) + # if BOTH is set, neither FCST or OBS can be set c_dict['USING_BOTH'] = False if c_dict['BOTH_INPUT_TEMPLATE']: @@ -269,9 +303,9 @@ def create_c_dict(self): False) ) - c_dict['VAR_LIST_TEMP'] = parse_var_list(self.config, - met_tool=self.app_name) - if not c_dict['VAR_LIST_TEMP']: + c_dict['VAR_LIST'] = parse_var_list(self.config, + met_tool=self.app_name) + if not c_dict['VAR_LIST']: self.log_error("No fields specified. Please set " "[FCST/OBS]_VAR_[NAME/LEVELS]") @@ -320,7 +354,7 @@ def create_c_dict(self): return c_dict - def plot_data_plane_init(self): + def _plot_data_plane_init(self): """! Set values to allow successful initialization of PlotDataPlane wrapper @@ -421,20 +455,14 @@ def run_at_time_once(self, time_info, lead_group=None): if not storm_list: return False - # perform string substitution on var list - self.c_dict['VAR_LIST'] = ( - util.sub_var_list(self.c_dict['VAR_LIST_TEMP'], - time_info) - ) - # loop over storm list and process for each # this loop will execute once if not filtering by storm ID for storm_id in storm_list: # Create FCST and OBS ASCII files fcst_path, obs_path = ( - self.create_ascii_storm_files_list(time_info, - storm_id, - lead_group) + self._create_ascii_storm_files_list(time_info, + storm_id, + lead_group) ) if not fcst_path or not obs_path: self.log_error('No ASCII file lists were created. Skipping.') @@ -447,9 +475,9 @@ def run_at_time_once(self, time_info, lead_group=None): continue if self.c_dict['GENERATE_PLOTS']: - self.generate_plots(fcst_path, - time_info, - storm_id) + self._generate_plots(fcst_path, + time_info, + storm_id) else: self.logger.debug("Skip plotting output. Change " "SERIES_ANALYSIS_GENERATE_PLOTS to True to " @@ -579,7 +607,7 @@ def compare_time_info(self, runtime, filetime): return bool(filetime['storm_id'] == runtime['storm_id']) - def create_ascii_storm_files_list(self, time_info, storm_id, lead_group): + def _create_ascii_storm_files_list(self, time_info, storm_id, lead_group): """! Creates the list of ASCII files that contain the storm id and init times. The list is used to create an ASCII file which will be used as the option to the -obs or -fcst flag to the MET @@ -619,7 +647,7 @@ def create_ascii_storm_files_list(self, time_info, storm_id, lead_group): output_dir = self.get_output_dir(time_info, storm_id, label) - if not self.check_python_embedding(): + if not self._check_python_embedding(): return None, None # create forecast (or both) file list @@ -627,9 +655,9 @@ def create_ascii_storm_files_list(self, time_info, storm_id, lead_group): data_type = 'BOTH' else: data_type = 'FCST' - fcst_ascii_filename = self.get_ascii_filename(data_type, - storm_id, - leads) + fcst_ascii_filename = self._get_ascii_filename(data_type, + storm_id, + leads) self.write_list_file(fcst_ascii_filename, all_fcst_files, output_dir=output_dir) @@ -640,7 +668,7 @@ def create_ascii_storm_files_list(self, time_info, storm_id, lead_group): return fcst_path, fcst_path # create analysis file list - obs_ascii_filename = self.get_ascii_filename('OBS', + obs_ascii_filename = self._get_ascii_filename('OBS', storm_id, leads) self.write_list_file(obs_ascii_filename, @@ -651,7 +679,7 @@ def create_ascii_storm_files_list(self, time_info, storm_id, lead_group): return fcst_path, obs_path - def check_python_embedding(self): + def _check_python_embedding(self): """! Check if any of the field names contain a Python embedding script. See CommandBuilder.check_for_python_embedding for more info. @@ -670,7 +698,7 @@ def check_python_embedding(self): return True @staticmethod - def get_ascii_filename(data_type, storm_id, leads=None): + def _get_ascii_filename(data_type, storm_id, leads=None): """! Build filename for ASCII file list file @param data_type FCST, OBS, or BOTH @@ -769,10 +797,13 @@ def build_and_run_series_request(self, time_info, fcst_path, obs_path): else: self.c_dict['FCST_LIST_PATH'] = fcst_path self.c_dict['OBS_LIST_PATH'] = obs_path + self.add_field_info_to_time_info(time_info, var_info) # get formatted field dictionary to pass into the MET config file - fcst_field, obs_field = self.get_formatted_fields(var_info) + fcst_field, obs_field = self.get_formatted_fields(var_info, + fcst_path, + obs_path) self.format_field('FCST', fcst_field) self.format_field('OBS', obs_field) @@ -800,9 +831,7 @@ def set_environment_variables(self, time_info): """ self.logger.info('Setting env variables from config file...') - # Set all the environment variables that are needed by the - # MET config file. - # Set up the environment variable to be used in the Series Analysis + # Set all the environment variables referenced in the MET config file self.add_env_var("FCST_FILE_TYPE", self.c_dict.get('FCST_FILE_TYPE', '')) self.add_env_var("OBS_FILE_TYPE", self.c_dict.get('OBS_FILE_TYPE', @@ -868,9 +897,10 @@ def get_command(self): cmd += ' -v ' + self.c_dict['VERBOSITY'] return cmd - def generate_plots(self, fcst_path, time_info, storm_id): + def _generate_plots(self, fcst_path, time_info, storm_id): """! Generate the plots from the series_analysis output. + @param fcst_path path to forecast file list file @param time_info dictionary containing time information @param storm_id storm ID to process """ @@ -903,8 +933,8 @@ def generate_plots(self, fcst_path, time_info, storm_id): self.logger.debug(f"Skipping plot for {storm_id}") continue - _, nseries = self.get_netcdf_min_max(plot_input, - 'series_cnt_TOTAL') + _, nseries = self._get_netcdf_min_max(plot_input, + 'series_cnt_TOTAL') nseries_str = '' if nseries is None else f" (N = {nseries})" time_info['nseries'] = nseries_str @@ -915,8 +945,8 @@ def generate_plots(self, fcst_path, time_info, storm_id): self.c_dict['PNG_FILES'][key] = [] min_value, max_value = ( - self.get_netcdf_min_max(plot_input, - f'series_cnt_{cur_stat}') + self._get_netcdf_min_max(plot_input, + f'series_cnt_{cur_stat}') ) range_min_max = f"{min_value} {max_value}" @@ -994,14 +1024,9 @@ def get_fcst_file_info(self, fcst_path): files_of_interest = files_of_interest[1:] num = str(len(files_of_interest)) - if self.c_dict['USING_BOTH']: - input_dir = self.c_dict['BOTH_INPUT_DIR'] - input_template = self.c_dict['BOTH_INPUT_TEMPLATE'] - else: - input_dir = self.c_dict['FCST_INPUT_DIR'] - input_template = self.c_dict['FCST_INPUT_TEMPLATE'] - - full_template = os.path.join(input_dir, input_template) + data_type = 'BOTH' if self.c_dict['USING_BOTH'] else 'FCST' + template = os.path.join(self.c_dict[f'{data_type}_INPUT_DIR'], + self.c_dict[f'{data_type}_INPUT_TEMPLATE']) smallest_fcst = 99999999 largest_fcst = -99999999 @@ -1009,7 +1034,7 @@ def get_fcst_file_info(self, fcst_path): end = None for filepath in files_of_interest: filepath = filepath.strip() - file_time_info = parse_template(full_template, + file_time_info = parse_template(template, filepath, self.logger) if not file_time_info: @@ -1029,7 +1054,7 @@ def get_fcst_file_info(self, fcst_path): return num, beg, end @staticmethod - def get_netcdf_min_max(filepath, variable_name): + def _get_netcdf_min_max(filepath, variable_name): """! Determine the min and max for all lead times for each statistic and variable pairing. @@ -1046,7 +1071,7 @@ def get_netcdf_min_max(filepath, variable_name): except (FileNotFoundError, KeyError): return None, None - def get_formatted_fields(self, var_info): + def get_formatted_fields(self, var_info, fcst_path, obs_path): """! Get forecast and observation field information for var_info and format it so it can be passed into the MET config file @@ -1054,23 +1079,63 @@ def get_formatted_fields(self, var_info): @returns tuple containing strings of the formatted forecast and observation information or None, None if something went wrong """ - # get field info field a single field to pass to the MET config file - fcst_field_list = self.get_field_info(v_level=var_info['fcst_level'], - v_thresh=var_info['fcst_thresh'], - v_name=var_info['fcst_name'], - v_extra=var_info['fcst_extra'], - d_type='FCST') - - obs_field_list = self.get_field_info(v_level=var_info['obs_level'], - v_thresh=var_info['obs_thresh'], - v_name=var_info['obs_name'], - v_extra=var_info['obs_extra'], - d_type='OBS') - - if fcst_field_list is None or obs_field_list is None: + fcst_field_list = self._get_field_list('fcst', var_info, obs_path) + obs_field_list = self._get_field_list('obs', var_info, fcst_path) + + if not fcst_field_list or not obs_field_list: return None, None fcst_fields = ','.join(fcst_field_list) obs_fields = ','.join(obs_field_list) return fcst_fields, obs_fields + + def _get_field_list(self, data_type, var_info, file_list_path): + other = 'OBS' if data_type == 'fcst' else 'FCST' + # check if time filename template tags are used in field level + if not self._has_time_tag(var_info[f'{data_type}_level']): + # get field info for a single field to pass to the MET config file + return self.get_field_info( + v_level=var_info[f'{data_type}_level'], + v_thresh=var_info[f'{data_type}_thresh'], + v_name=var_info[f'{data_type}_name'], + v_extra=var_info[f'{data_type}_extra'], + d_type=data_type.upper() + ) + + field_list = [] + # loop through fcst and obs files to extract time info + template = os.path.join(self.c_dict[f'{other}_INPUT_DIR'], + self.c_dict[f'{other}_INPUT_TEMPLATE']) + # for each file apply time info to field info and add to list + for file_time_info in self._get_times_from_file_list(file_list_path, + template): + level = do_string_sub(var_info[f'{data_type}_level'], + **file_time_info) + field = self.get_field_info( + v_level=level, + v_thresh=var_info[f'{data_type}_thresh'], + v_name=var_info[f'{data_type}_name'], + v_extra=var_info[f'{data_type}_extra'], + d_type=data_type.upper() + ) + if field: + field_list.extend(field) + + return field_list + + @staticmethod + def _has_time_tag(level): + return any(item in ['init', 'valid', 'lead'] + for item in get_tags(level)) + + @staticmethod + def _get_times_from_file_list(file_path, template): + with open(file_path, 'r') as file_handle: + file_list = file_handle.read().splitlines()[1:] + + for file_name in file_list: + file_time_info = parse_template(template, file_name) + if not file_time_info: + continue + yield file_time_info diff --git a/parm/met_config/SeriesAnalysisConfig_wrapped b/parm/met_config/SeriesAnalysisConfig_wrapped index a8cf1af226..864153e9eb 100644 --- a/parm/met_config/SeriesAnalysisConfig_wrapped +++ b/parm/met_config/SeriesAnalysisConfig_wrapped @@ -9,16 +9,19 @@ // // Output model name to be written // +//model = ${METPLUS_MODEL} // // Output description to be written // +//desc = ${METPLUS_DESC} // // Output observation type to be written // +//obtype = ${METPLUS_OBTYPE} //////////////////////////////////////////////////////////////////////////////// @@ -27,12 +30,14 @@ ${METPLUS_OBTYPE} // Verification grid // May be set separately in each "field" entry // +//regrid = { ${METPLUS_REGRID_DICT} //////////////////////////////////////////////////////////////////////////////// censor_thresh = []; censor_val = []; +//cat_thresh = ${METPLUS_CAT_THRESH} cnt_thresh = [ NA ]; cnt_logic = UNION; @@ -42,10 +47,12 @@ cnt_logic = UNION; // fcst = { ${METPLUS_FCST_FILE_TYPE} + ${METPLUS_FCST_CAT_THRESH} ${METPLUS_FCST_FIELD} } obs = { ${METPLUS_OBS_FILE_TYPE} + ${METPLUS_OBS_CAT_THRESH} ${METPLUS_OBS_FIELD} } @@ -90,11 +97,13 @@ mask = { // Number of grid points to be processed concurrently. Set smaller to use // less memory but increase the number of passes through the data. // +//block_size = ${METPLUS_BLOCK_SIZE} // // Ratio of valid matched pairs to compute statistics for a grid point // +//vld_thresh = ${METPLUS_VLD_THRESH} //////////////////////////////////////////////////////////////////////////////// diff --git a/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf b/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf index 230c081f1d..3c8f768fcb 100644 --- a/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf +++ b/parm/use_cases/met_tool_wrapper/SeriesAnalysis/SeriesAnalysis.conf @@ -50,6 +50,9 @@ SERIES_ANALYSIS_CLIMO_STDEV_INPUT_TEMPLATE = MODEL = WRF OBTYPE = MC_PCP +#FCST_CAT_THRESH = +#OBS_CAT_THRESH = + FCST_VAR1_NAME = APCP FCST_VAR1_LEVELS = A03 @@ -92,6 +95,7 @@ SERIES_ANALYSIS_IS_PAIRED = False #SERIES_ANALYSIS_CLIMO_MEAN_MATCH_MONTH = #SERIES_ANALYSIS_CLIMO_MEAN_DAY_INTERVAL = #SERIES_ANALYSIS_CLIMO_MEAN_HOUR_INTERVAL = +#SERIES_ANALYSIS_CLIMO_MEAN_FILE_TYPE = #SERIES_ANALYSIS_CLIMO_STDEV_FILE_NAME = #SERIES_ANALYSIS_CLIMO_STDEV_FIELD = @@ -103,6 +107,7 @@ SERIES_ANALYSIS_IS_PAIRED = False #SERIES_ANALYSIS_CLIMO_STDEV_MATCH_MONTH = #SERIES_ANALYSIS_CLIMO_STDEV_DAY_INTERVAL = #SERIES_ANALYSIS_CLIMO_STDEV_HOUR_INTERVAL = +#SERIES_ANALYSIS_CLIMO_STDEV_FILE_TYPE = #SERIES_ANALYSIS_HSS_EC_VALUE = From c77d5d508152b301ea6dac81bcc034a12defbd8d Mon Sep 17 00:00:00 2001 From: j-opatz <59586397+j-opatz@users.noreply.github.com> Date: Thu, 13 Jan 2022 10:25:43 -0700 Subject: [PATCH 35/43] Feature 1116 usecase smos (#1348) Co-authored-by: Mrinal Biswas --- .github/parm/use_case_groups.json | 5 + ...GridStat_fcstRTOFS_obsSMOS_climWOA_sss.png | Bin 0 -> 189578 bytes .../GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py | 170 +++++++++ internal_tests/use_cases/all_use_cases.txt | 1 + ...ridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf | 267 ++++++++++++++ .../read_rtofs_smos_woa.py | 346 ++++++++++++++++++ 6 files changed, 789 insertions(+) create mode 100644 docs/_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMOS_climWOA_sss.png create mode 100644 docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py create mode 100644 parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf create mode 100644 parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/read_rtofs_smos_woa.py diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 6c5f13f407..9fc35f1847 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -59,6 +59,11 @@ "index_list": "0-2", "run": false }, + { + "category": "marine_and_cryosphere", + "index_list": "3", + "run": true + }, { "category": "medium_range", "index_list": "0", diff --git a/docs/_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMOS_climWOA_sss.png b/docs/_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMOS_climWOA_sss.png new file mode 100644 index 0000000000000000000000000000000000000000..59435cf80338f0a313820a0c422e7102aa694fec GIT binary patch literal 189578 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

=}!O&bf;16A9bKNpr&QORhx^EqL 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%0D}43Q(&r3YMup-f(2x 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 -r1ojDmW_bS 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 for more details. +# +# **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** +# +# If there is a setting in the MET configuration file that is currently not supported by METplus you'd like to control, please refer to: +# :ref:`Overriding Unsupported MET config file settings` +# +# .. note:: See the :ref:`GridStat MET Configuration` section of the User's Guide for more information on the environment variables used in the file below: +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/met_config/GridStatConfig_wrapped + +############################################################################## +# Python Embedding +# ---------------- +# +# This use case uses one Python script to read forecast and observation data +# +# parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/read_rtofs_smos_woa.py +# +# .. highlight:: python +# .. literalinclude:: ../../../../parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/read_rtofs_smos_woa.py +# + +############################################################################## +# Running METplus +# --------------- +# +# This use case can be run two ways: +# +# 1) Passing in GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf then a user-specific system configuration file:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf -c /path/to/user_system.conf +# +# 2) Modifying the configurations in parm/metplus_config, then passing in GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf +# +# The former method is recommended. Whether you add them to a user-specific configuration file or modify the metplus_config files, the following variables must be set correctly: +# +# * **INPUT_BASE** - Path to directory where sample data tarballs are unpacked (See Datasets section to obtain tarballs). This is not required to run METplus, but it is required to run the examples in parm/use_cases +# * **OUTPUT_BASE** - Path where METplus output will be written. This must be in a location where you have write permissions +# * **MET_INSTALL_DIR** - Path to location where MET is installed locally +# +# Example User Configuration File:: +# +# [dir] +# INPUT_BASE = /path/to/sample/input/data +# OUTPUT_BASE = /path/to/output/dir +# MET_INSTALL_DIR = /path/to/met-X.Y +# +# **NOTE:** All of these items must be found under the [dir] section. +# + +############################################################################## +# Expected Output +# --------------- +# +# A successful run will output the following both to the screen and to the logfile:: +# +# INFO: METplus has successfully finished running. +# +# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. +# Output for thisIce use case will be found in 20210503 (relative to **OUTPUT_BASE**) +# and will contain the following files: +# +# * grid_stat_SSS_000000L_20210503_000000V.stat +# * grid_stat_SSS_000000L_20210503_000000V_cnt.txt +# * grid_stat_SSS_000000L_20210503_000000V_pairs.nc + +############################################################################## +# Keywords +# -------- +# +# .. note:: +# +# * GridStatToolUseCase +# * PythonEmbeddingFileUseCase +# * MarineAndCryosphereAppUseCase +# +# Navigate to the :ref:`quick-search` page to discover other similar use cases. +# +# +# +# sphinx_gallery_thumbnail_path = '_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMOS_climWOA_sss.png' + diff --git a/internal_tests/use_cases/all_use_cases.txt b/internal_tests/use_cases/all_use_cases.txt index b6cd0a3af5..2582f198e9 100644 --- a/internal_tests/use_cases/all_use_cases.txt +++ b/internal_tests/use_cases/all_use_cases.txt @@ -89,6 +89,7 @@ Category: marine_and_cryosphere 0::GridStat_MODE_fcstIMS_obsNCEP_sea_ice::model_applications/marine_and_cryosphere/GridStat_MODE_fcstIMS_obsNCEP_sea_ice.conf 1::PlotDataPlane_obsHYCOM_coordTripolar::model_applications/marine_and_cryosphere/PlotDataPlane_obsHYCOM_coordTripolar.conf:: xesmf_env, py_embed 2::GridStat_fcstRTOFS_obsOSTIA_iceCover::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsOSTIA_iceCover.conf:: icecover_env, py_embed +3::GridStat_fcstRTOFS_obsSMOS_climWOA_sss::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf:: icecover_env, py_embed #X::GridStat_fcstRTOFS_obsGHRSST_climWOA_sst::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst.conf, model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst/ci_overrides.conf:: icecover_env, py_embed diff --git a/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf new file mode 100644 index 0000000000..72e97f663e --- /dev/null +++ b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf @@ -0,0 +1,267 @@ +# GridStat METplus Configuration + +# section heading for [config] variables - all items below this line and +# before the next section heading correspond to the [config] section +[config] + +# List of applications to run - only GridStat for this case +PROCESS_LIST = GridStat + +# time looping - options are INIT, VALID, RETRO, and REALTIME +# If set to INIT or RETRO: +# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set +# If set to VALID or REALTIME: +# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set +LOOP_BY = VALID + +# Format of INIT_BEG and INT_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d + +# Start time for METplus run - must match INIT_TIME_FMT +VALID_BEG=20210503 + +# End time for METplus run - must match INIT_TIME_FMT +VALID_END=20210503 + +# Increment between METplus runs (in seconds if no units are specified) +# Must be >= 60 seconds +VALID_INCREMENT = 1M + +# List of forecast leads to process for each run time (init or valid) +# In hours if units are not specified +# If unset, defaults to 0 (don't loop through forecast leads) +LEAD_SEQ = 0 + + +# Order of loops to process data - Options are times, processes +# Not relevant if only one item is in the PROCESS_LIST +# times = run all wrappers in the PROCESS_LIST for a single run time, then +# increment the run time and run all wrappers again until all times have +# been evaluated. +# processes = run the first wrapper in the PROCESS_LIST for all times +# specified, then repeat for the next item in the PROCESS_LIST until all +# wrappers have been run +LOOP_ORDER = times + +# Verbosity of MET output - overrides LOG_VERBOSITY for GridStat only +LOG_GRID_STAT_VERBOSITY = 2 + +# Location of MET config file to pass to GridStat +GRID_STAT_CONFIG_FILE = {PARM_BASE}/met_config/GridStatConfig_wrapped + +# grid to remap data. Value is set as the 'to_grid' variable in the 'regrid' dictionary +# See MET User's Guide for more information +GRID_STAT_REGRID_TO_GRID = NONE + +#GRID_STAT_INTERP_FIELD = +#GRID_STAT_INTERP_VLD_THRESH = +#GRID_STAT_INTERP_SHAPE = +#GRID_STAT_INTERP_TYPE_METHOD = +#GRID_STAT_INTERP_TYPE_WIDTH = + +#GRID_STAT_NC_PAIRS_VAR_NAME = + +#GRID_STAT_CLIMO_MEAN_TIME_INTERP_METHOD = +#GRID_STAT_CLIMO_STDEV_TIME_INTERP_METHOD = + +#GRID_STAT_GRID_WEIGHT_FLAG = AREA + +# Name to identify model (forecast) data in output +MODEL = RTOFS + +# Name to identify observation data in output +OBTYPE = SMOS + +# set the desc value in the GridStat MET config file +GRID_STAT_DESC = NA + +# List of variables to compare in GridStat - FCST_VAR1 variables correspond +# to OBS_VAR1 variables +# Note [FCST/OBS/BOTH]_GRID_STAT_VAR_NAME can be used instead if different evaluations +# are needed for different tools + +# Name of forecast variable 1 +FCST_VAR1_NAME = {CONFIG_DIR}/read_rtofs_smos_woa.py {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/{valid?fmt=%Y%m%d}_rtofs_glo_2ds_f024_prog.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/SMOS-L3-GLOB_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/OSTIA-UKMO-L4-GLOB-v2.0_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss {valid?fmt=%Y%m%d} fcst + +# List of levels to evaluate for forecast variable 1 +# A03 = 3 hour accumulation in GRIB file +FCST_VAR1_LEVELS = + +# List of thresholds to evaluate for each name/level combination for +# forecast variable 1 +FCST_VAR1_THRESH = + +#FCST_GRID_STAT_FILE_TYPE = + +# Name of observation variable 1 +OBS_VAR1_NAME = {CONFIG_DIR}/read_rtofs_smos_woa.py {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/{valid?fmt=%Y%m%d}_rtofs_glo_2ds_f024_prog.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/SMOS-L3-GLOB_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/OSTIA-UKMO-L4-GLOB-v2.0_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss {valid?fmt=%Y%m%d} obs + + +# List of levels to evaluate for observation variable 1 +# (*,*) is NetCDF notation - must include quotes around these values! +# must be the same length as FCST_VAR1_LEVELS +OBS_VAR1_LEVELS = + +# List of thresholds to evaluate for each name/level combination for +# observation variable 1 +OBS_VAR1_THRESH = + +#GRID_STAT_MET_CONFIG_OVERRIDES = cat_thresh = [>=0.15]; +#BOTH_VAR1_THRESH = >=0.15 + +#OBS_GRID_STAT_FILE_TYPE = + + +# Name of climatology variable 1 +GRID_STAT_CLIMO_MEAN_FIELD = {name="{CONFIG_DIR}/read_rtofs_smos_woa.py {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/{valid?fmt=%Y%m%d}_rtofs_glo_2ds_f024_prog.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/SMOS-L3-GLOB_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/OSTIA-UKMO-L4-GLOB-v2.0_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss {valid?fmt=%Y%m%d} climo"; level="(*,*)";} + + +# Time relative to valid time (in seconds) to allow files to be considered +# valid. Set both BEGIN and END to 0 to require the exact time in the filename +# Not used in this example. +FCST_GRID_STAT_FILE_WINDOW_BEGIN = 0 +FCST_GRID_STAT_FILE_WINDOW_END = 0 +OBS_GRID_STAT_FILE_WINDOW_BEGIN = 0 +OBS_GRID_STAT_FILE_WINDOW_END = 0 + +# MET GridStat neighborhood values +# See the MET User's Guide GridStat section for more information + +# width value passed to nbrhd dictionary in the MET config file +GRID_STAT_NEIGHBORHOOD_WIDTH = 1 + +# shape value passed to nbrhd dictionary in the MET config file +GRID_STAT_NEIGHBORHOOD_SHAPE = SQUARE + +# cov thresh list passed to nbrhd dictionary in the MET config file +GRID_STAT_NEIGHBORHOOD_COV_THRESH = >=0.5 + +# Set to true to run GridStat separately for each field specified +# Set to false to create one run of GridStat per run time that +# includes all fields specified. +GRID_STAT_ONCE_PER_FIELD = False + +# Set to true if forecast data is probabilistic +FCST_IS_PROB = false + +# Only used if FCST_IS_PROB is true - sets probabilistic threshold +FCST_GRID_STAT_PROB_THRESH = ==0.1 + +# Set to true if observation data is probabilistic +# Only used if configuring forecast data as the 'OBS' input +OBS_IS_PROB = false + +# Only used if OBS_IS_PROB is true - sets probabilistic threshold +OBS_GRID_STAT_PROB_THRESH = ==0.1 + +GRID_STAT_OUTPUT_PREFIX = SSS + +#GRID_STAT_CLIMO_MEAN_FILE_NAME = +#GRID_STAT_CLIMO_MEAN_FIELD = +#GRID_STAT_CLIMO_MEAN_REGRID_METHOD = +#GRID_STAT_CLIMO_MEAN_REGRID_WIDTH = +#GRID_STAT_CLIMO_MEAN_REGRID_VLD_THRESH = +#GRID_STAT_CLIMO_MEAN_REGRID_SHAPE = +#GRID_STAT_CLIMO_MEAN_TIME_INTERP_METHOD = +#GRID_STAT_CLIMO_MEAN_MATCH_MONTH = +#GRID_STAT_CLIMO_MEAN_DAY_INTERVAL = +#GRID_STAT_CLIMO_MEAN_HOUR_INTERVAL = + +#GRID_STAT_CLIMO_STDEV_FILE_NAME = +#GRID_STAT_CLIMO_STDEV_FIELD = +#GRID_STAT_CLIMO_STDEV_REGRID_METHOD = +#GRID_STAT_CLIMO_STDEV_REGRID_WIDTH = +#GRID_STAT_CLIMO_STDEV_REGRID_VLD_THRESH = +#GRID_STAT_CLIMO_STDEV_REGRID_SHAPE = +#GRID_STAT_CLIMO_STDEV_TIME_INTERP_METHOD = +#GRID_STAT_CLIMO_STDEV_MATCH_MONTH = +#GRID_STAT_CLIMO_STDEV_DAY_INTERVAL = +#GRID_STAT_CLIMO_STDEV_HOUR_INTERVAL = + + +#GRID_STAT_CLIMO_CDF_BINS = 1 +#GRID_STAT_CLIMO_CDF_CENTER_BINS = False +#GRID_STAT_CLIMO_CDF_WRITE_BINS = True + +#GRID_STAT_OUTPUT_FLAG_FHO = NONE +#GRID_STAT_OUTPUT_FLAG_CTC = NONE +#GRID_STAT_OUTPUT_FLAG_CTS = NONE +#GRID_STAT_OUTPUT_FLAG_MCTC = NONE +#GRID_STAT_OUTPUT_FLAG_MCTS = NONE +GRID_STAT_OUTPUT_FLAG_CNT = BOTH +#GRID_STAT_OUTPUT_FLAG_SL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_SAL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_VL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_VAL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_VCNT = NONE +#GRID_STAT_OUTPUT_FLAG_PCT = NONE +#GRID_STAT_OUTPUT_FLAG_PSTD = NONE +#GRID_STAT_OUTPUT_FLAG_PJC = NONE +#GRID_STAT_OUTPUT_FLAG_PRC = NONE +#GRID_STAT_OUTPUT_FLAG_ECLV = BOTH +#GRID_STAT_OUTPUT_FLAG_NBRCTC = NONE +#GRID_STAT_OUTPUT_FLAG_NBRCTS = NONE +#GRID_STAT_OUTPUT_FLAG_NBRCNT = NONE +#GRID_STAT_OUTPUT_FLAG_GRAD = BOTH +#GRID_STAT_OUTPUT_FLAG_DMAP = NONE + +#GRID_STAT_NC_PAIRS_FLAG_LATLON = FALSE +#GRID_STAT_NC_PAIRS_FLAG_RAW = FALSE +#GRID_STAT_NC_PAIRS_FLAG_DIFF = FALSE +#GRID_STAT_NC_PAIRS_FLAG_CLIMO = FALSE +#GRID_STAT_NC_PAIRS_FLAG_CLIMO_CDP = FALSE +#GRID_STAT_NC_PAIRS_FLAG_WEIGHT = FALSE +#GRID_STAT_NC_PAIRS_FLAG_NBRHD = FALSE +#GRID_STAT_NC_PAIRS_FLAG_FOURIER = FALSE +#GRID_STAT_NC_PAIRS_FLAG_GRADIENT = FALSE +#GRID_STAT_NC_PAIRS_FLAG_DISTANCE_MAP = FALSE +#GRID_STAT_NC_PAIRS_FLAG_APPLY_MASK = FALSE + + +# End of [config] section and start of [dir] section +[dir] +#use case configuration file directory +CONFIG_DIR = {PARM_BASE}/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss +# directory containing forecast input to GridStat +FCST_GRID_STAT_INPUT_DIR = + +# directory containing observation input to GridStat +OBS_GRID_STAT_INPUT_DIR = + +# directory containing climatology mean input to GridStat +# Not used in this example +GRID_STAT_CLIMO_MEAN_INPUT_DIR = + +# directory containing climatology mean input to GridStat +# Not used in this example +GRID_STAT_CLIMO_STDEV_INPUT_DIR = + +# directory to write output from GridStat +GRID_STAT_OUTPUT_DIR = {OUTPUT_BASE} + +# End of [dir] section and start of [filename_templates] section +[filename_templates] + +# Template to look for forecast input to GridStat relative to FCST_GRID_STAT_INPUT_DIR +FCST_GRID_STAT_INPUT_TEMPLATE = PYTHON_NUMPY + +# Template to look for observation input to GridStat relative to OBS_GRID_STAT_INPUT_DIR +OBS_GRID_STAT_INPUT_TEMPLATE = PYTHON_NUMPY + +# Optional subdirectories relative to GRID_STAT_OUTPUT_DIR to write output from GridStat +GRID_STAT_OUTPUT_TEMPLATE = {valid?fmt=%Y%m%d} + +# Template to look for climatology input to GridStat relative to GRID_STAT_CLIMO_MEAN_INPUT_DIR +# Not used in this example +GRID_STAT_CLIMO_MEAN_INPUT_TEMPLATE = PYTHON_NUMPY + +# Template to look for climatology input to GridStat relative to GRID_STAT_CLIMO_STDEV_INPUT_DIR +# Not used in this exampls +GRID_STAT_CLIMO_STDEV_INPUT_TEMPLATE = + +# Used to specify one or more verification mask files for GridStat +# Not used for this example +GRID_STAT_VERIFICATION_MASK_TEMPLATE = diff --git a/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/read_rtofs_smos_woa.py b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/read_rtofs_smos_woa.py new file mode 100644 index 0000000000..04017cf6c0 --- /dev/null +++ b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss/read_rtofs_smos_woa.py @@ -0,0 +1,346 @@ +#!/bin/env python +""" +Code adapted from +Todd Spindler +NOAA/NWS/NCEP/EMC +Designed to read in RTOFS,SMOS,WOA and OSTIA data +and based on user input, read sss data +and pass back in memory the forecast, observation, or climatology +data field +""" + +import numpy as np +import xarray as xr +import pandas as pd +import pyresample as pyr +from pandas.tseries.offsets import DateOffset +from datetime import datetime, timedelta +from sklearn.metrics import mean_squared_error +import io +from glob import glob +import warnings +import os, sys + + +if len(sys.argv) < 6: + print("Must specify the following elements: fcst_file obs_file ice_file, climo_file, valid_date, file_flag") + sys.exit(1) + +rtofsfile = os.path.expandvars(sys.argv[1]) +sssfile = os.path.expandvars(sys.argv[2]) +icefile = os.path.expandvars(sys.argv[3]) +climoDir = os.path.expandvars(sys.argv[4]) +vDate=datetime.strptime(sys.argv[5],'%Y%m%d') +file_flag = sys.argv[6] + +print('Starting Satellite SMOS V&V at',datetime.now(),'for',vDate, ' file_flag:',file_flag) + +pd.date_range(vDate,vDate) +platform='SMOS' +param='sss' + + +##################################################################### +# READ SMOS data ################################################## +##################################################################### + +if not os.path.exists(sssfile): + print('missing SMOS file for',vDate) + +sss_data=xr.open_dataset(sssfile,decode_times=True) +sss_data['time']=sss_data.time-pd.Timedelta('12H') # shift 12Z offset time to 00Z +sss_data2=sss_data['sss'].astype('single') +print('Retrieved SMOS data from NESDIS for',sss_data2.time.values) +sss_data2=sss_data2.rename({'longitude':'lon','latitude':'lat'}) + + +# all coords need to be single precision +sss_data2['lon']=sss_data2.lon.astype('single') +sss_data2['lat']=sss_data2.lat.astype('single') +sss_data2.attrs['platform']=platform +sss_data2.attrs['units']='PSU' + +##################################################################### +# READ RTOFS data (model output in Tri-polar coordinates) ########### +##################################################################### + +print('reading rtofs ice') +if not os.path.exists(rtofsfile): + print('missing rtofs file',rtofsfile) + sys.exit(1) + +indata=xr.open_dataset(rtofsfile,decode_times=True) + + +indata=indata.mean(dim='MT') +indata = indata[param][:-1,] +indata.coords['time']=vDate +#indata.coords['fcst']=fcst + +outdata=indata.copy() + +outdata=outdata.rename({'Longitude':'lon','Latitude':'lat',}) +# all coords need to be single precision +outdata['lon']=outdata.lon.astype('single') +outdata['lat']=outdata.lat.astype('single') +outdata.attrs['platform']='rtofs '+platform + +##################################################################### +# READ CLIMO WOA data - May require 2 files depending on the date ### +##################################################################### + +if not os.path.exists(climoDir): + print('missing climo file file for',vDate) + +vDate=pd.Timestamp(vDate) + +climofile="woa13_decav_s{:02n}_04v2.nc".format(vDate.month) +climo_data=xr.open_dataset(climoDir+'/'+climofile,decode_times=False) +climo_data=climo_data['s_an'].squeeze()[0,] + +if vDate.day==15: # even for Feb, just because + climofile="woa13_decav_s{:02n}_04v2.nc".format(vDate.month) + climo_data=xr.open_dataset(climoDir+'/'+climofile,decode_times=False) + climo_data=climo_data['s_an'].squeeze()[0,] # surface only +else: + if vDate.day < 15: + start=vDate - DateOffset(months=1,day=15) + stop=pd.Timestamp(vDate.year,vDate.month,15) + else: + start=pd.Timestamp(vDate.year,vDate.month,15) + stop=vDate + DateOffset(months=1,day=15) + left=(vDate-start)/(stop-start) + + climofile1="woa13_decav_s{:02n}_04v2.nc".format(start.month) + climofile2="woa13_decav_s{:02n}_04v2.nc".format(stop.month) + climo_data1=xr.open_dataset(climoDir+'/'+climofile1,decode_times=False) + climo_data2=xr.open_dataset(climoDir+'/'+climofile2,decode_times=False) + climo_data1=climo_data1['s_an'].squeeze()[0,] # surface only + climo_data2=climo_data2['s_an'].squeeze()[0,] # surface only + + print('climofile1 :', climofile1) + print('climofile2 :', climofile2) + climo_data=climo_data1+((climo_data2-climo_data1)*left) + climofile='weighted average of '+climofile1+' and '+climofile2 + +# all coords need to be single precision +climo_data['lon']=climo_data.lon.astype('single') +climo_data['lat']=climo_data.lat.astype('single') +climo_data.attrs['platform']='woa' +climo_data.attrs['filename']=climofile + +##################################################################### +# READ ICE data for masking ######################################### +##################################################################### + +if not os.path.exists(icefile): + print('missing OSTIA ice file for',vDate) + +ice_data=xr.open_dataset(icefile,decode_times=True) +ice_data=ice_data.rename({'sea_ice_fraction':'ice'}) + +# all coords need to be single precision +ice_data2=ice_data.ice.astype('single') +ice_data2['lon']=ice_data2.lon.astype('single') +ice_data2['lat']=ice_data2.lat.astype('single') + + +def regrid(model,obs): + """ + regrid data to obs -- this assumes DataArrays + """ + model2=model.copy() + model2_lon=model2.lon.values + model2_lat=model2.lat.values + model2_data=model2.to_masked_array() + if model2_lon.ndim==1: + model2_lon,model2_lat=np.meshgrid(model2_lon,model2_lat) + + obs2=obs.copy() + obs2_lon=obs2.lon.astype('single').values + obs2_lat=obs2.lat.astype('single').values + obs2_data=obs2.astype('single').to_masked_array() + if obs2.lon.ndim==1: + obs2_lon,obs2_lat=np.meshgrid(obs2.lon.values,obs2.lat.values) + + model2_lon1=pyr.utils.wrap_longitudes(model2_lon) + model2_lat1=model2_lat.copy() + obs2_lon1=pyr.utils.wrap_longitudes(obs2_lon) + obs2_lat1=obs2_lat.copy() + + # pyresample gausssian-weighted kd-tree interp + # define the grids + orig_def = pyr.geometry.GridDefinition(lons=model2_lon1,lats=model2_lat1) + targ_def = pyr.geometry.GridDefinition(lons=obs2_lon1,lats=obs2_lat1) + radius=50000 + sigmas=25000 + model2_data2=pyr.kd_tree.resample_gauss(orig_def,model2_data,targ_def, + radius_of_influence=radius, + sigmas=sigmas, + fill_value=None) + model=xr.DataArray(model2_data2,coords=[obs.lat.values,obs.lon.values],dims=['lat','lon']) + + return model + +def expand_grid(data): + """ + concatenate global data for edge wraps + """ + + data2=data.copy() + data2['lon']=data2.lon+360 + data3=xr.concat((data,data2),dim='lon') + return data3 + +sss_data2=sss_data2.squeeze() + +print('regridding climo to obs') +climo_data=climo_data.squeeze() +climo_data=regrid(climo_data,sss_data2) + +print('regridding ice to obs') +ice_data2=regrid(ice_data2,sss_data2) + +print('regridding model to obs') +model2=regrid(outdata,sss_data2) + +# combine obs ice mask with ncep +obs2=sss_data2.to_masked_array() +ice2=ice_data2.to_masked_array() +climo2=climo_data.to_masked_array() +model2=model2.to_masked_array() + +#reconcile with obs +obs2.mask=np.ma.mask_or(obs2.mask,ice2>0.0) +obs2.mask=np.ma.mask_or(obs2.mask,climo2.mask) +obs2.mask=np.ma.mask_or(obs2.mask,model2.mask) +climo2.mask=obs2.mask +model2.mask=obs2.mask + +obs2=xr.DataArray(obs2,coords=[sss_data2.lat.values,sss_data2.lon.values], dims=['lat','lon']) +model2=xr.DataArray(model2,coords=[sss_data2.lat.values,sss_data2.lon.values], dims=['lat','lon']) +climo2=xr.DataArray(climo2,coords=[sss_data2.lat.values,sss_data2.lon.values], dims=['lat','lon']) + +model2=expand_grid(model2) +climo2=expand_grid(climo2) +obs2=expand_grid(obs2) + +#Create the MET grids based on the file_flag +if file_flag == 'fcst': + met_data = model2[:,:] + #trim the lat/lon grids so they match the data fields + lat_met = model2.lat + lon_met = model2.lon + print(" RTOFS Data shape: "+repr(met_data.shape)) + v_str = vDate.strftime("%Y%m%d") + v_str = v_str + '_000000' + lat_ll = float(lat_met.min()) + lon_ll = float(lon_met.min()) + n_lat = lat_met.shape[0] + n_lon = lon_met.shape[0] + delta_lat = (float(lat_met.max()) - float(lat_met.min()))/float(n_lat) + delta_lon = (float(lon_met.max()) - float(lon_met.min()))/float(n_lon) + print(f"variables:" + f"lat_ll: {lat_ll} lon_ll: {lon_ll} n_lat: {n_lat} n_lon: {n_lon} delta_lat: {delta_lat} delta_lon: {delta_lon}") + met_data.attrs = { + 'valid': v_str, + 'init': v_str, + 'lead': "00", + 'accum': "00", + 'name': 'sss', + 'standard_name': 'sss', + 'long_name': 'sss', + 'level': "SURFACE", + 'units': "degC", + + 'grid': { + 'type': "LatLon", + 'name': "RTOFS Grid", + 'lat_ll': lat_ll, + 'lon_ll': lon_ll, + 'delta_lat': delta_lat, + 'delta_lon': delta_lon, + 'Nlat': n_lat, + 'Nlon': n_lon, + } + } + attrs = met_data.attrs + +if file_flag == 'obs': + met_data = obs2[:,:] + #trim the lat/lon grids so they match the data fields + lat_met = obs2.lat + lon_met = obs2.lon + v_str = vDate.strftime("%Y%m%d") + v_str = v_str + '_000000' + lat_ll = float(lat_met.min()) + lon_ll = float(lon_met.min()) + n_lat = lat_met.shape[0] + n_lon = lon_met.shape[0] + delta_lat = (float(lat_met.max()) - float(lat_met.min()))/float(n_lat) + delta_lon = (float(lon_met.max()) - float(lon_met.min()))/float(n_lon) + print(f"variables:" + f"lat_ll: {lat_ll} lon_ll: {lon_ll} n_lat: {n_lat} n_lon: {n_lon} delta_lat: {delta_lat} delta_lon: {delta_lon}") + met_data.attrs = { + 'valid': v_str, + 'init': v_str, + 'lead': "00", + 'accum': "00", + 'name': 'sss', + 'standard_name': 'analyzed sss', + 'long_name': 'analyzed sss', + 'level': "SURFACE", + 'units': "degC", + + 'grid': { + 'type': "LatLon", + 'name': "Lat Lon", + 'lat_ll': lat_ll, + 'lon_ll': lon_ll, + 'delta_lat': delta_lat, + 'delta_lon': delta_lon, + 'Nlat': n_lat, + 'Nlon': n_lon, + } + } + attrs = met_data.attrs + +if file_flag == 'climo': + met_data = climo2[:,:] + #modify the lat and lon grids since they need to match the data dimensions, and code cuts the last row/column of data + lat_met = climo2.lat + lon_met = climo2.lon + v_str = vDate.strftime("%Y%m%d") + v_str = v_str + '_000000' + lat_ll = float(lat_met.min()) + lon_ll = float(lon_met.min()) + n_lat = lat_met.shape[0] + n_lon = lon_met.shape[0] + delta_lat = (float(lat_met.max()) - float(lat_met.min()))/float(n_lat) + delta_lon = (float(lon_met.max()) - float(lon_met.min()))/float(n_lon) + print(f"variables:" + f"lat_ll: {lat_ll} lon_ll: {lon_ll} n_lat: {n_lat} n_lon: {n_lon} delta_lat: {delta_lat} delta_lon: {delta_lon}") + met_data.attrs = { + 'valid': v_str, + 'init': v_str, + 'lead': "00", + 'accum': "00", + 'name': 'sea_water_temperature', + 'standard_name': 'sea_water_temperature', + 'long_name': 'sea_water_temperature', + 'level': "SURFACE", + 'units': "degC", + + 'grid': { + 'type': "LatLon", + 'name': "crs Grid", + 'lat_ll': lat_ll, + 'lon_ll': lon_ll, + 'delta_lat': delta_lat, + 'delta_lon': delta_lon, + 'Nlat': n_lat, + 'Nlon': n_lon, + } + } + attrs = met_data.attrs + From 6890f39d7db6f0c78c7f834eb60cbed585bec8a0 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 13 Jan 2022 10:26:31 -0700 Subject: [PATCH 36/43] turn off new use case from every push --- .github/parm/use_case_groups.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 9fc35f1847..5413a364c3 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -62,7 +62,7 @@ { "category": "marine_and_cryosphere", "index_list": "3", - "run": true + "run": false }, { "category": "medium_range", From 8ef160961408de06de3940aca91d77f3aaa41b67 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Thu, 13 Jan 2022 14:11:26 -0700 Subject: [PATCH 37/43] feature 1236 Control Members in EnsembleStat and GenEnsProd (#1357) --- docs/Users_Guide/glossary.rst | 36 +++ docs/Users_Guide/wrappers.rst | 50 ++++ .../test_ensemble_stat_wrapper.py | 6 + .../gen_ens_prod/test_gen_ens_prod_wrapper.py | 5 + metplus/wrappers/ensemble_stat_wrapper.py | 18 ++ metplus/wrappers/gen_ens_prod_wrapper.py | 11 +- parm/met_config/EnsembleStatConfig_wrapped | 6 + parm/met_config/GenEnsProdConfig_wrapped | 7 + .../EnsembleStat/EnsembleStat.conf | 242 +++++++----------- .../GenEnsProd/GenEnsProd.conf | 3 + 10 files changed, 232 insertions(+), 152 deletions(-) diff --git a/docs/Users_Guide/glossary.rst b/docs/Users_Guide/glossary.rst index f3e3f1ec54..4b3c5e6a65 100644 --- a/docs/Users_Guide/glossary.rst +++ b/docs/Users_Guide/glossary.rst @@ -8723,3 +8723,39 @@ METplus Configuration Glossary case and the values differ between them. | *Used by:* All + + GEN_ENS_PROD_ENS_MEMBER_IDS + Specify the value for 'ens_member_ids' in the MET configuration file for GenEnsProd. + + | *Used by:* GenEnsProd + + GEN_ENS_PROD_CONTROL_ID + Specify the value for 'control_id' in the MET configuration file for GenEnsProd. + + | *Used by:* GenEnsProd + + ENSEMBLE_STAT_ENS_MEMBER_IDS + Specify the value for 'ens_member_ids' in the MET configuration file for EnsembleStat. + + | *Used by:* EnsembleStat + + ENSEMBLE_STAT_CONTROL_ID + Specify the value for 'control_id' in the MET configuration file for EnsembleStat. + + | *Used by:* EnsembleStat + + ENSEMBLE_STAT_CTRL_INPUT_DIR + Input directory for optional control file to use with EnsembleStat. + See also :term:`ENSEMBLE_STAT_CTRL_INPUT_TEMPLATE`. + + | *Used by:* EnsembleStat + + ENSEMBLE_STAT_CTRL_INPUT_TEMPLATE + Template used to specify an optional control filename for EnsembleStat. + Note that if a control member file is found in the ensemble file list, + it will automatically be removed by the wrapper to prevent an error in the + MET tool. This may require adjusting the value for + :term:`ENSEMBLE_STAT_N_MEMBERS` and/or + :term:`ENSEMBLE_STAT_ENS_VLD_THRESH`. + + | *Used by:* EnsembleStat diff --git a/docs/Users_Guide/wrappers.rst b/docs/Users_Guide/wrappers.rst index e06bee76fa..5976640376 100644 --- a/docs/Users_Guide/wrappers.rst +++ b/docs/Users_Guide/wrappers.rst @@ -183,6 +183,8 @@ METplus Configuration | :term:`OBS_ENSEMBLE_STAT_GRID_INPUT_TEMPLATE` | :term:`FCST_ENSEMBLE_STAT_INPUT_TEMPLATE` | :term:`ENSEMBLE_STAT_OUTPUT_TEMPLATE` +| :term:`ENSEMBLE_STAT_CTRL_INPUT_DIR` +| :term:`ENSEMBLE_STAT_CTRL_INPUT_TEMPLATE` | :term:`LOG_ENSEMBLE_STAT_VERBOSITY` | :term:`FCST_ENSEMBLE_STAT_INPUT_DATATYPE` | :term:`OBS_ENSEMBLE_STAT_INPUT_POINT_DATATYPE` @@ -277,6 +279,8 @@ METplus Configuration | :term:`ENSEMBLE_STAT_OBS_QUALITY_INC` | :term:`ENSEMBLE_STAT_OBS_QUALITY_EXC` | :term:`ENSEMBLE_STAT_MET_CONFIG_OVERRIDES` +| :term:`ENSEMBLE_STAT_ENS_MEMBER_IDS` +| :term:`ENSEMBLE_STAT_CONTROL_ID` | :term:`ENSEMBLE_STAT_VERIFICATION_MASK_TEMPLATE` (optional) | :term:`ENS_VAR_NAME` (optional) | :term:`ENS_VAR_LEVELS` (optional) @@ -853,6 +857,28 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`ENSEMBLE_STAT_OBS_QUALITY_EXC` - obs_quality_exc +**${METPLUS_ENS_MEMBER_IDS}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`ENSEMBLE_STAT_ENS_MEMBER_IDS` + - ens_member_ids + +**${METPLUS_CONTROL_ID}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`ENSEMBLE_STAT_CONTROL_ID` + - control_id + **${METPLUS_MET_CONFIG_OVERRIDES}** .. list-table:: @@ -1049,6 +1075,8 @@ METplus Configuration | :term:`GEN_ENS_PROD_ENSEMBLE_FLAG_NMEP` | :term:`GEN_ENS_PROD_ENSEMBLE_FLAG_CLIMO` | :term:`GEN_ENS_PROD_ENSEMBLE_FLAG_CLIMO_CDF` +| :term:`GEN_ENS_PROD_ENS_MEMBER_IDS` +| :term:`GEN_ENS_PROD_CONTROL_ID` | :term:`GEN_ENS_PROD_MET_CONFIG_OVERRIDES` .. _gen-ens-prod-met-conf: @@ -1339,6 +1367,28 @@ see :ref:`How METplus controls MET config file settings`. * - :term:`GEN_ENS_PROD_ENSEMBLE_FLAG_CLIMO_CDF` - ensemble_flag.climo_cdf +**${METPLUS_ENS_MEMBER_IDS}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`GEN_ENS_PROD_ENS_MEMBER_IDS` + - ens_member_ids + +**${METPLUS_CONTROL_ID}** + +.. list-table:: + :widths: 5 5 + :header-rows: 0 + + * - METplus Config(s) + - MET Config File + * - :term:`GEN_ENS_PROD_CONTROL_ID` + - control_id + **${METPLUS_MET_CONFIG_OVERRIDES}** .. list-table:: diff --git a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py index 1f864709a1..1ef41cf8e8 100644 --- a/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py +++ b/internal_tests/pytests/ensemble_stat/test_ensemble_stat_wrapper.py @@ -547,6 +547,12 @@ def test_handle_climo_file_variables(metplus_config, config_overrides, ({'ENSEMBLE_STAT_OBS_QUALITY_EXC': '5,6,7', }, {'METPLUS_OBS_QUALITY_EXC': 'obs_quality_exc = ["5", "6", "7"];'}), + ({'ENSEMBLE_STAT_ENS_MEMBER_IDS': '1,2,3,4', }, + {'METPLUS_ENS_MEMBER_IDS': 'ens_member_ids = ["1", "2", "3", "4"];'}), + + ({'ENSEMBLE_STAT_CONTROL_ID': '0', }, + {'METPLUS_CONTROL_ID': 'control_id = "0";'}), + ] ) def test_ensemble_stat_single_field(metplus_config, config_overrides, diff --git a/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py b/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py index 60703aecb2..0ccc92117d 100644 --- a/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py +++ b/internal_tests/pytests/gen_ens_prod/test_gen_ens_prod_wrapper.py @@ -343,6 +343,11 @@ def set_minimum_config_settings(config): ) }), + ({'GEN_ENS_PROD_ENS_MEMBER_IDS': '1,2,3,4', }, + {'METPLUS_ENS_MEMBER_IDS': 'ens_member_ids = ["1", "2", "3", "4"];'}), + + ({'GEN_ENS_PROD_CONTROL_ID': '0', }, + {'METPLUS_CONTROL_ID': 'control_id = "0";'}), ] ) def test_gen_ens_prod_single_field(metplus_config, config_overrides, diff --git a/metplus/wrappers/ensemble_stat_wrapper.py b/metplus/wrappers/ensemble_stat_wrapper.py index 0bc64cd76b..0d8e0141af 100755 --- a/metplus/wrappers/ensemble_stat_wrapper.py +++ b/metplus/wrappers/ensemble_stat_wrapper.py @@ -64,6 +64,8 @@ class EnsembleStatWrapper(CompareGriddedWrapper): 'METPLUS_OUTPUT_PREFIX', 'METPLUS_OBS_QUALITY_INC', 'METPLUS_OBS_QUALITY_EXC', + 'METPLUS_ENS_MEMBER_IDS', + 'METPLUS_CONTROL_ID', ] # handle deprecated env vars used pre v4.0.0 @@ -203,6 +205,16 @@ def create_c_dict(self): '') ) + # get ctrl (control) template/dir - optional + c_dict['CTRL_INPUT_TEMPLATE'] = self.config.getraw( + 'config', + 'ENSEMBLE_STAT_CTRL_INPUT_TEMPLATE' + ) + c_dict['CTRL_INPUT_DIR'] = self.config.getdir( + 'ENSEMBLE_STAT_CTRL_INPUT_DIR', + '' + ) + # get climatology config variables self.handle_climo_dict() @@ -312,6 +324,12 @@ def create_c_dict(self): 'ENSEMBLE_STAT_OBS_QUALITY_EXCLUDE'] ) + self.add_met_config(name='ens_member_ids', + data_type='list') + + self.add_met_config(name='control_id', + data_type='string') + # old method of setting MET config values c_dict['ENS_THRESH'] = ( self.config.getstr('config', 'ENSEMBLE_STAT_ENS_THRESH', '1.0') diff --git a/metplus/wrappers/gen_ens_prod_wrapper.py b/metplus/wrappers/gen_ens_prod_wrapper.py index e8011fb0bd..5df8bbb1c3 100755 --- a/metplus/wrappers/gen_ens_prod_wrapper.py +++ b/metplus/wrappers/gen_ens_prod_wrapper.py @@ -30,6 +30,8 @@ class GenEnsProdWrapper(LoopTimesWrapper): 'METPLUS_CLIMO_MEAN_DICT', 'METPLUS_CLIMO_STDEV_DICT', 'METPLUS_ENSEMBLE_FLAG_DICT', + 'METPLUS_ENS_MEMBER_IDS', + 'METPLUS_CONTROL_ID', ] ENSEMBLE_FLAGS = [ @@ -199,6 +201,12 @@ def create_c_dict(self): self.handle_flags('ENSEMBLE') + self.add_met_config(name='ens_member_ids', + data_type='list') + + self.add_met_config(name='control_id', + data_type='string') + c_dict['ALLOW_MULTIPLE_FILES'] = True return c_dict @@ -257,5 +265,6 @@ def get_command(self): @return command to run """ return (f"{self.app_path} -v {self.c_dict['VERBOSITY']}" - f" -ens {self.infiles[0]} -out {self.get_output_path()}" + f" -ens {self.infiles[0]}" + f" -out {self.get_output_path()}" f" {' '.join(self.args)}") diff --git a/parm/met_config/EnsembleStatConfig_wrapped b/parm/met_config/EnsembleStatConfig_wrapped index 6374340917..e398ca1d1d 100644 --- a/parm/met_config/EnsembleStatConfig_wrapped +++ b/parm/met_config/EnsembleStatConfig_wrapped @@ -53,6 +53,12 @@ ens = { ${METPLUS_ENS_FIELD} } +//ens_member_ids = +${METPLUS_ENS_MEMBER_IDS} + +//control_id = +${METPLUS_CONTROL_ID} + //////////////////////////////////////////////////////////////////////////////// // diff --git a/parm/met_config/GenEnsProdConfig_wrapped b/parm/met_config/GenEnsProdConfig_wrapped index 2da107e1d4..9171881b35 100644 --- a/parm/met_config/GenEnsProdConfig_wrapped +++ b/parm/met_config/GenEnsProdConfig_wrapped @@ -63,6 +63,13 @@ ens = { } +//ens_member_ids = +${METPLUS_ENS_MEMBER_IDS} + +//control_id = +${METPLUS_CONTROL_ID} + + //////////////////////////////////////////////////////////////////////////////// // diff --git a/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf b/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf index c4c61ce8e7..c7714c0291 100644 --- a/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf +++ b/parm/use_cases/met_tool_wrapper/EnsembleStat/EnsembleStat.conf @@ -1,84 +1,117 @@ -# Ensemble Stat -# This METplus conf file runs the MET met_test unit test ensemble_stat command. -#ensemble_stat \ -# 6 \ -# /path/totrunk/met/data/sample_fcst/2009123112/*gep*/d01_2009123112_02400.grib \ -# /path/totrunk/met/scripts/config/EnsembleStatConfig \ -# -grid_obs /path/to/trunk/met/data/sample_obs/ST4/ST4.2010010112.24h \ -# -point_obs /path/to/MET_test_output/met_test_scripts/ascii2nc/precip24_2010010112.nc \ -# -outdir /path/to/MET_test_output/met_test_scripts/ensemble_stat \ -# -v 2 - [config] -## Configuration-related settings such as the process list, begin and end times, etc. PROCESS_LIST = EnsembleStat -# Looping by times: steps through each 'task' in the PROCESS_LIST for each -# defined time, and repeats until all times have been evaluated. -LOOP_ORDER = times +### +# Time Info +### -# LOOP_BY: Set to INIT to loop over initialization times LOOP_BY = INIT - -# Format of INIT_BEG and INT_END INIT_TIME_FMT = %Y%m%d%H - -# Start time for METplus run INIT_BEG=2009123112 - -# End time for METplus run INIT_END=2009123112 - -# Increment between METplus runs in seconds. Must be >= 60 INIT_INCREMENT=3600 -# List of forecast leads to process -LEAD_SEQ = 24 +LEAD_SEQ = 24H -# Used in the MET config file for: model, output_prefix -MODEL = WRF +LOOP_ORDER = times -ENSEMBLE_STAT_DESC = NA -# Name to identify observation data in output +### +# File I/O +### + +FCST_ENSEMBLE_STAT_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst +FCST_ENSEMBLE_STAT_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/arw-???-gep?/d01_{init?fmt=%Y%m%d%H}_0{lead?fmt=%HH}00.grib + +#ENSEMBLE_STAT_CTRL_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst +#ENSEMBLE_STAT_CTRL_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/arw-fer-gep1/d01_{init?fmt=%Y%m%d%H}_0{lead?fmt=%HH}00.grib + +ENSEMBLE_STAT_N_MEMBERS = 6 + + +OBS_ENSEMBLE_STAT_POINT_INPUT_DIR = {INPUT_BASE}/met_test/out/ascii2nc +OBS_ENSEMBLE_STAT_POINT_INPUT_TEMPLATE = precip24_{valid?fmt=%Y%m%d%H}.nc + + +OBS_ENSEMBLE_STAT_GRID_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_obs/ST4 +OBS_ENSEMBLE_STAT_GRID_INPUT_TEMPLATE = ST4.{valid?fmt=%Y%m%d%H}.24h + + +ENSEMBLE_STAT_CLIMO_MEAN_INPUT_DIR = +ENSEMBLE_STAT_CLIMO_MEAN_INPUT_TEMPLATE = + +ENSEMBLE_STAT_CLIMO_STDEV_INPUT_DIR = +ENSEMBLE_STAT_CLIMO_STDEV_INPUT_TEMPLATE = + + +ENSEMBLE_STAT_OUTPUT_DIR = {OUTPUT_BASE}/ensemble +ENSEMBLE_STAT_OUTPUT_TEMPLATE = {init?fmt=%Y%m%d%H%M}/ensemble_stat + + +### +# Field Info +### + +MODEL = WRF OBTYPE = MC_PCP -#ENSEMBLE_STAT_DESC = -# The MET ensemble_stat logging level -# 0 quiet to 5 loud, Verbosity setting for MET ensemble_stat output, 2 is default. -# This takes precendence over the general LOG_MET_VERBOSITY set in metplus_logging.conf +FCST_VAR1_NAME = APCP +FCST_VAR1_LEVELS = A24 +FCST_VAR1_OPTIONS = ens_ssvar_bin_size = 0.1; ens_phist_bin_size = 0.05; + + +OBS_VAR1_NAME = {FCST_VAR1_NAME} +OBS_VAR1_LEVELS = {FCST_VAR1_LEVELS} +OBS_VAR1_OPTIONS = {FCST_VAR1_OPTIONS} + + +ENS_VAR1_NAME = APCP +ENS_VAR1_LEVELS = A24 +ENS_VAR1_THRESH = >0.0, >=10.0 + +ENS_VAR2_NAME = REFC +ENS_VAR2_LEVELS = L0 +ENS_VAR2_THRESH = >=35.0 + +ENS_VAR2_OPTIONS = GRIB1_ptv = 129; + +ENS_VAR3_NAME = UGRD +ENS_VAR3_LEVELS = Z10 +ENS_VAR3_THRESH = >=5.0 + +ENS_VAR4_NAME = VGRD +ENS_VAR4_LEVELS = Z10 +ENS_VAR4_THRESH = >=5.0 + +ENS_VAR5_NAME = WIND +ENS_VAR5_LEVELS = Z10 +ENS_VAR5_THRESH = >=5.0 + + +### +# EnsembleStat +### + #LOG_ENSEMBLE_STAT_VERBOSITY = 2 +ENSEMBLE_STAT_CONFIG_FILE = {PARM_BASE}/met_config/EnsembleStatConfig_wrapped + +ENSEMBLE_STAT_DESC = NA + OBS_ENSEMBLE_STAT_WINDOW_BEGIN = -5400 OBS_ENSEMBLE_STAT_WINDOW_END = 5400 -OBS_FILE_WINDOW_BEGIN = 0 -OBS_FILE_WINDOW_END = 0 - -# number of expected members for ensemble. Should correspond with the -# number of items in the list for FCST_ENSEMBLE_STAT_INPUT_TEMPLATE -ENSEMBLE_STAT_N_MEMBERS = 6 -# ens.ens_thresh value in the MET config file -# threshold for ratio of valid files to expected files to allow app to run ENSEMBLE_STAT_ENS_THRESH = 1.0 -# ens.vld_thresh value in the MET config file ENSEMBLE_STAT_ENS_VLD_THRESH = 1.0 ENSEMBLE_STAT_OUTPUT_PREFIX = -ENSEMBLE_STAT_CONFIG_FILE = {PARM_BASE}/met_config/EnsembleStatConfig_wrapped - -# ENSEMBLE_STAT_MET_OBS_ERR_TABLE is not required. -# If the variable is not defined, or the value is not set -# than the MET default is used. #ENSEMBLE_STAT_MET_OBS_ERR_TABLE = - -# Used in the MET config file for: regrid to_grid field ENSEMBLE_STAT_REGRID_TO_GRID = NONE ENSEMBLE_STAT_REGRID_METHOD = NEAREST ENSEMBLE_STAT_REGRID_WIDTH = 1 @@ -131,12 +164,17 @@ ENSEMBLE_STAT_ENS_PHIST_BIN_SIZE = 0.05 #ENSEMBLE_STAT_CLIMO_STDEV_DAY_INTERVAL = 31 #ENSEMBLE_STAT_CLIMO_STDEV_HOUR_INTERVAL = 6 - ENSEMBLE_STAT_CLIMO_CDF_BINS = 1 ENSEMBLE_STAT_CLIMO_CDF_CENTER_BINS = False ENSEMBLE_STAT_CLIMO_CDF_WRITE_BINS = True ENSEMBLE_STAT_MASK_GRID = FULL +ENSEMBLE_STAT_MASK_POLY = + MET_BASE/poly/HMT_masks/huc4_1605_poly.nc, + MET_BASE/poly/HMT_masks/huc4_1803_poly.nc, + MET_BASE/poly/HMT_masks/huc4_1804_poly.nc, + MET_BASE/poly/HMT_masks/huc4_1805_poly.nc, + MET_BASE/poly/HMT_masks/huc4_1806_poly.nc ENSEMBLE_STAT_CI_ALPHA = 0.05 @@ -172,103 +210,5 @@ ENSEMBLE_STAT_ENSEMBLE_FLAG_WEIGHT = FALSE #ENSEMBLE_STAT_OBS_QUALITY_INC = #ENSEMBLE_STAT_OBS_QUALITY_EXC = -# Ensemble Variables and levels as specified in the ens field dictionary -# of the MET configuration file. Specify as ENS_VARn_NAME, ENS_VARn_LEVELS, -# (optional) ENS_VARn_OPTION -ENS_VAR1_NAME = APCP -ENS_VAR1_LEVELS = A24 -ENS_VAR1_THRESH = >0.0, >=10.0 - -ENS_VAR2_NAME = REFC -ENS_VAR2_LEVELS = L0 -ENS_VAR2_THRESH = >=35.0 - -ENS_VAR2_OPTIONS = GRIB1_ptv = 129; - -ENS_VAR3_NAME = UGRD -ENS_VAR3_LEVELS = Z10 -ENS_VAR3_THRESH = >=5.0 - -ENS_VAR4_NAME = VGRD -ENS_VAR4_LEVELS = Z10 -ENS_VAR4_THRESH = >=5.0 - -ENS_VAR5_NAME = WIND -ENS_VAR5_LEVELS = Z10 -ENS_VAR5_THRESH = >=5.0 - - - -# Forecast Variables and levels as specified in the fcst field dictionary -# of the MET configuration file. Specify as FCST_VARn_NAME, FCST_VARn_LEVELS, -# (optional) FCST_VARn_OPTION -FCST_VAR1_NAME = APCP -FCST_VAR1_LEVELS = A24 - -FCST_VAR1_OPTIONS = ens_ssvar_bin_size = 0.1; ens_phist_bin_size = 0.05; - - -# Observation Variables and levels as specified in the obs field dictionary -# of the MET configuration file. Specify as OBS_VARn_NAME, OBS_VARn_LEVELS, -# (optional) OBS_VARn_OPTION -OBS_VAR1_NAME = {FCST_VAR1_NAME} -OBS_VAR1_LEVELS = {FCST_VAR1_LEVELS} - -OBS_VAR1_OPTIONS = {FCST_VAR1_OPTIONS} - - -[dir] -# Forecast model input directory for ensemble_stat -FCST_ENSEMBLE_STAT_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_fcst - -# Point observation input dir for ensemble_stat -OBS_ENSEMBLE_STAT_POINT_INPUT_DIR = {INPUT_BASE}/met_test/out/ascii2nc - -# Grid observation input dir for ensemble_stat -OBS_ENSEMBLE_STAT_GRID_INPUT_DIR = {INPUT_BASE}/met_test/data/sample_obs/ST4 - -# directory containing climatology mean input to EnsembleStat -# Not used in this example -ENSEMBLE_STAT_CLIMO_MEAN_INPUT_DIR = - -# directory containing climatology mean input to EnsembleStat -# Not used in this example -ENSEMBLE_STAT_CLIMO_STDEV_INPUT_DIR = - -# output directory for ensemble_stat -ENSEMBLE_STAT_OUTPUT_DIR = {OUTPUT_BASE}/ensemble - - -[filename_templates] - -# FCST_ENSEMBLE_STAT_INPUT_TEMPLATE - comma separated list of ensemble members -# or a single line, - filename wildcard characters may be used, ? or *. - -FCST_ENSEMBLE_STAT_INPUT_TEMPLATE = {init?fmt=%Y%m%d%H}/arw-???-gep?/d01_{init?fmt=%Y%m%d%H}_0{lead?fmt=%HH}00.grib - -# Template to look for point observations. -# Example precip24_2010010112.nc -OBS_ENSEMBLE_STAT_POINT_INPUT_TEMPLATE = precip24_{valid?fmt=%Y%m%d%H}.nc - -# Template to look for gridded observations. -# Example ST4.2010010112.24h -OBS_ENSEMBLE_STAT_GRID_INPUT_TEMPLATE = ST4.{valid?fmt=%Y%m%d%H}.24h - -ENSEMBLE_STAT_VERIFICATION_MASK_TEMPLATE = - MET_BASE/poly/HMT_masks/huc4_1605_poly.nc, - MET_BASE/poly/HMT_masks/huc4_1803_poly.nc, - MET_BASE/poly/HMT_masks/huc4_1804_poly.nc, - MET_BASE/poly/HMT_masks/huc4_1805_poly.nc, - MET_BASE/poly/HMT_masks/huc4_1806_poly.nc - -# Template to look for climatology input to EnsembleStat relative to ENSEMBLE_STAT_CLIMO_MEAN_INPUT_DIR -# Not used in this example -ENSEMBLE_STAT_CLIMO_MEAN_INPUT_TEMPLATE = - -# Template to look for climatology input to EnsembleStat relative to ENSEMBLE_STAT_CLIMO_STDEV_INPUT_DIR -# Not used in this example -ENSEMBLE_STAT_CLIMO_STDEV_INPUT_TEMPLATE = - - -ENSEMBLE_STAT_OUTPUT_TEMPLATE = {init?fmt=%Y%m%d%H%M}/ensemble_stat - +#ENSEMBLE_STAT_ENS_MEMBER_IDS = +#ENSEMBLE_STAT_CONTROL_ID = diff --git a/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf b/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf index b545614bde..0b576567e8 100644 --- a/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf +++ b/parm/use_cases/met_tool_wrapper/GenEnsProd/GenEnsProd.conf @@ -138,3 +138,6 @@ GEN_ENS_PROD_ENS_THRESH = 0.8 # GEN_ENS_PROD_ENSEMBLE_FLAG_NMEP = FALSE # GEN_ENS_PROD_ENSEMBLE_FLAG_CLIMO = FALSE # GEN_ENS_PROD_ENSEMBLE_FLAG_CLIMO_CDF = FALSE + +#GEN_ENS_PROD_ENS_MEMBER_IDS = +#GEN_ENS_PROD_CONTROL_ID = From 41add20d016c1d0122cf6d3906402cefb8ee5929 Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 14 Jan 2022 16:31:46 -0700 Subject: [PATCH 38/43] added optional argument to change the directory to untar new input data into so the same Dockerfile can be used to add data for other METplus components such as MET --- ci/docker/docker_data/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ci/docker/docker_data/Dockerfile b/ci/docker/docker_data/Dockerfile index 1f883181c4..b5889ec1fd 100644 --- a/ci/docker/docker_data/Dockerfile +++ b/ci/docker/docker_data/Dockerfile @@ -17,7 +17,8 @@ RUN if [ "x${MOUNTPT}" == "x" ]; then \ exit 1; \ fi -ENV CASE_DIR=/data/input/METplus_Data +ARG DATA_DIR=/data/input/METplus_Data +ENV CASE_DIR=${DATA_DIR} RUN mkdir -p ${CASE_DIR} RUN for URL in `echo ${TARFILE_URL} | tr "," " "`; do \ From 61c5d185fda82af35b066a31f64e1f764114981b Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 14 Jan 2022 16:57:55 -0700 Subject: [PATCH 39/43] feature 1358 v4.1.0-beta5 release (#1359) --- docs/Users_Guide/release-notes.rst | 24 ++++++++++++++++++++++++ metplus/VERSION | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/Users_Guide/release-notes.rst b/docs/Users_Guide/release-notes.rst index 00078ee855..b54ec5fd43 100644 --- a/docs/Users_Guide/release-notes.rst +++ b/docs/Users_Guide/release-notes.rst @@ -38,6 +38,30 @@ When applicable, release notes are followed by the GitHub issue number which describes the bugfix, enhancement, or new feature: https://github.com/dtcenter/METplus/issues + +METplus Version 4.1.0-beta5 Release Notes (2022-01-14) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* Enhancements: + + * **Add support for setting control members in EnsembleStat and GenEnsProd** (`#1236 `_) + * **Enhance SeriesAnalysis wrapper to allow different field info values for each file in a list** (`#1166 `_) + * Add support for setting INIT_LIST and VALID_LIST for irregular time intervals (`#1286 `_) + * Support setting the OMP_NUM_THREADS environment variable (`#1320 `_) + * Enhance ExtractTiles using MTD input to properly match times (`#1285 `_) + * Add support for commonly changed MET config variables part 2 (`#896 `_) + * Prevent wildcard character from being used in output file path (`#1291 `_) + +* New Use Cases: + + * Satellite verification of sea surface salinity: SMOS vs RTOFS output (`#1116 `_) + +* Internal: + + * **Create guidance for memory-intensive use cases, introduce Python memory profiler** (`#1183 `_) + * **Identify code throughout METplus components that are common utilities** (`#799 `_) + * **Add definitions to the Release Guide for the stages of the release cycle** (`#934 `_) + METplus Version 4.1.0-beta4 Release Notes (2021-11-16) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/metplus/VERSION b/metplus/VERSION index bcc7104f60..ef715f86b9 100644 --- a/metplus/VERSION +++ b/metplus/VERSION @@ -1 +1 @@ -4.1.0-beta5-dev \ No newline at end of file +4.1.0-beta5 \ No newline at end of file From 9aa9058673be7ef234d7b22e482ba65494079e5d Mon Sep 17 00:00:00 2001 From: George McCabe <23407799+georgemccabe@users.noreply.github.com> Date: Fri, 14 Jan 2022 17:00:02 -0700 Subject: [PATCH 40/43] update version for next development cycle --- metplus/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metplus/VERSION b/metplus/VERSION index ef715f86b9..ce8a4f6331 100644 --- a/metplus/VERSION +++ b/metplus/VERSION @@ -1 +1 @@ -4.1.0-beta5 \ No newline at end of file +4.1.0-beta6-dev \ No newline at end of file From d8dd6159b4f5eca792d98a56c471f80c1e6dee04 Mon Sep 17 00:00:00 2001 From: j-opatz <59586397+j-opatz@users.noreply.github.com> Date: Wed, 19 Jan 2022 15:58:05 -0700 Subject: [PATCH 41/43] Feature 1216 usecase smap (#1361) * Adding a conf file for SMAP * Adding a directory to host the read file * Removing temp file * Updated the valid dates to match Todd's code * Adding documentation for SMAP case * Updates the valis dates to match Todd's code * Removing a tmp file * Typo in file name * Updating the input RTOFS to have the init time instead of the valid time ii the file name * updated file paths, tesing * updated use case descriptions, rearranged use case group testing * put new use case into its own group so that the diff logic can evaluate marine_and_cryosphere:3. The truth data for 3-4 does not exist yet so the diff fails. Co-authored-by: Mrinal Biswas Co-authored-by: George McCabe <23407799+georgemccabe@users.noreply.github.com> --- .github/parm/use_case_groups.json | 5 + ...GridStat_fcstRTOFS_obsSMAP_climWOA_sss.png | Bin 0 -> 297270 bytes .../GridStat_fcstRTOFS_obsSMAP_climWOA_sss.py | 170 +++++++++ .../GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py | 4 +- internal_tests/use_cases/all_use_cases.txt | 1 + ...ridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf | 267 ++++++++++++++ .../read_rtofs_smap_woa.py | 346 ++++++++++++++++++ 7 files changed, 791 insertions(+), 2 deletions(-) create mode 100644 docs/_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMAP_climWOA_sss.png create mode 100644 docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.py create mode 100644 parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf create mode 100644 parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/read_rtofs_smap_woa.py diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 5413a364c3..39eac94582 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -64,6 +64,11 @@ "index_list": "3", "run": false }, + { + "category": "marine_and_cryosphere", + "index_list": "4", + "run": false + }, { "category": "medium_range", "index_list": "0", diff --git a/docs/_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMAP_climWOA_sss.png b/docs/_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMAP_climWOA_sss.png new file mode 100644 index 0000000000000000000000000000000000000000..823dcfd3d9d0452c1b93f33092d039daade57ae7 GIT binary patch literal 297270 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�dyi;91O&fc~_KbcPlGTH)Bt? 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%0Dc2J~t2AR1I*q~}|zX#sIEy7IX?hnI?pYM(n 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�VRzKFOU&yy*`4BdX44 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

A$E;n_n6XcrF} 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Μ9g96p&k-kXHmX37F?Ddy#LUZSJ8{S&c&}nh3L#J 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 literal 0 HcmV?d00001 diff --git a/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.py b/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.py new file mode 100644 index 0000000000..8b9f5e18c3 --- /dev/null +++ b/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.py @@ -0,0 +1,170 @@ +""" +GridStat: Python Embedding for sea surface salinity using level 3, 8 day mean obs +================================================================================= + +model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf + +""" +############################################################################## +# Scientific Objective +# -------------------- +# +# This use case utilizes Python embedding to extract several statistics from the sea surface salinity data over the globe, +# which was already being done in a closed system. By producing the same output via METplus, this use case +# provides standardization and reproducible results. + +############################################################################## +# Datasets +# -------- +# +# | **Forecast:** RTOFS sss file via Python Embedding script/file +# +# | **Observations:** SMAP sss file via Python Embedding script/file +# +# | **Sea Ice Masking:** RTOFS ice cover file via Python Embedding script/file +# +# | **Climatology:** WOA sss file via Python Embedding script/file +# +# | **Location:** All of the input data required for this use case can be found in the met_test sample data tarball. Click here to the METplus releases page and download sample data for the appropriate release: https://github.com/dtcenter/METplus/releases +# | This tarball should be unpacked into the directory that you will set the value of INPUT_BASE. See `Running METplus`_ section for more information. +# +# | **Data Source:** JPL's PODAAC and NCEP's FTPPRD data servers +# | + +############################################################################## +# External Dependencies +# --------------------- +# +# You will need to use a version of Python 3.6+ that has the following packages installed: +# +# * scikit-learn +# * pyresample +# +# If the version of Python used to compile MET did not have these libraries at the time of compilation, you will need to add these packages or create a new Python environment with these packages. +# +# If this is the case, you will need to set the MET_PYTHON_EXE environment variable to the path of the version of Python you want to use. If you want this version of Python to only apply to this use case, set it in the [user_env_vars] section of a METplus configuration file.: +# +# [user_env_vars] +# MET_PYTHON_EXE = /path/to/python/with/required/packages/bin/python + +############################################################################## +# METplus Components +# ------------------ +# +# This use case utilizes the METplus GridStat wrapper to generate a +# command to run the MET tool GridStat with Python Embedding for the specified user hemispheres + +############################################################################## +# METplus Workflow +# ---------------- +# +# GridStat is the only tool called in this example. This use case will pass in both the observation, forecast, +# and climatology gridded data being pulled from the files via Python Embedding. All of the desired statistics +# reside in the CNT line type, so that is the only output requested. +# It processes the following run time: +# +# | **Valid:** 2021-05-02 0Z +# | + +############################################################################## +# METplus Configuration +# --------------------- +# +# METplus first loads all of the configuration files found in parm/metplus_config, +# then it loads any configuration files passed to METplus via the command line +# with the -c option, i.e. -c parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf + +############################################################################## +# MET Configuration +# --------------------- +# +# METplus sets environment variables based on user settings in the METplus configuration file. +# See :ref:`How METplus controls MET config file settings` for more details. +# +# **YOU SHOULD NOT SET ANY OF THESE ENVIRONMENT VARIABLES YOURSELF! THEY WILL BE OVERWRITTEN BY METPLUS WHEN IT CALLS THE MET TOOLS!** +# +# If there is a setting in the MET configuration file that is currently not supported by METplus you'd like to control, please refer to: +# :ref:`Overriding Unsupported MET config file settings` +# +# .. note:: See the :ref:`GridStat MET Configuration` section of the User's Guide for more information on the environment variables used in the file below: +# +# .. highlight:: bash +# .. literalinclude:: ../../../../parm/met_config/GridStatConfig_wrapped + +############################################################################## +# Python Embedding +# ---------------- +# +# This use case uses one Python script to read forecast and observation data +# +# parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/read_rtofs_smap_woa.py +# +# .. highlight:: python +# .. literalinclude:: ../../../../parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/read_rtofs_smap_woa.py +# + +############################################################################## +# Running METplus +# --------------- +# +# This use case can be run two ways: +# +# 1) Passing in GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf then a user-specific system configuration file:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf -c /path/to/user_system.conf +# +# 2) Modifying the configurations in parm/metplus_config, then passing in GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf:: +# +# run_metplus.py -c /path/to/METplus/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf +# +# The former method is recommended. Whether you add them to a user-specific configuration file or modify the metplus_config files, the following variables must be set correctly: +# +# * **INPUT_BASE** - Path to directory where sample data tarballs are unpacked (See Datasets section to obtain tarballs). This is not required to run METplus, but it is required to run the examples in parm/use_cases +# * **OUTPUT_BASE** - Path where METplus output will be written. This must be in a location where you have write permissions +# * **MET_INSTALL_DIR** - Path to location where MET is installed locally +# +# Example User Configuration File:: +# +# [dir] +# INPUT_BASE = /path/to/sample/input/data +# OUTPUT_BASE = /path/to/output/dir +# MET_INSTALL_DIR = /path/to/met-X.Y +# +# **NOTE:** All of these items must be found under the [dir] section. +# + +############################################################################## +# Expected Output +# --------------- +# +# A successful run will output the following both to the screen and to the logfile:: +# +# INFO: METplus has successfully finished running. +# +# Refer to the value set for **OUTPUT_BASE** to find where the output data was generated. +# Output for thisIce use case will be found in 20210503 (relative to **OUTPUT_BASE**) +# and will contain the following files: +# +# * grid_stat_SSS_000000L_20210502_000000V.stat +# * grid_stat_SSS_000000L_20210502_000000V_cnt.txt +# * grid_stat_SSS_000000L_20210502_000000V_pairs.nc + +############################################################################## +# Keywords +# -------- +# +# .. note:: +# +# * GridStatToolUseCase +# * PythonEmbeddingFileUseCase +# * MarineAndCryosphereAppUseCase +# +# Navigate to the :ref:`quick-search` page to discover other similar use cases. +# +# +# +# sphinx_gallery_thumbnail_path = '_static/marine_and_cryosphere-GridStat_fcstRTOFS_obsSMAP_climWOA_sss.png' + diff --git a/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py b/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py index 6321b24c2b..1788720dfd 100644 --- a/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py +++ b/docs/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.py @@ -1,6 +1,6 @@ """ -GridStat: Python Embedding to read and process ice cover -======================================================== +GridStat: Python Embedding for sea surface salinity using level 3, 1 day composite obs +====================================================================================== model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf diff --git a/internal_tests/use_cases/all_use_cases.txt b/internal_tests/use_cases/all_use_cases.txt index 2582f198e9..e1c9ad8e03 100644 --- a/internal_tests/use_cases/all_use_cases.txt +++ b/internal_tests/use_cases/all_use_cases.txt @@ -90,6 +90,7 @@ Category: marine_and_cryosphere 1::PlotDataPlane_obsHYCOM_coordTripolar::model_applications/marine_and_cryosphere/PlotDataPlane_obsHYCOM_coordTripolar.conf:: xesmf_env, py_embed 2::GridStat_fcstRTOFS_obsOSTIA_iceCover::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsOSTIA_iceCover.conf:: icecover_env, py_embed 3::GridStat_fcstRTOFS_obsSMOS_climWOA_sss::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMOS_climWOA_sss.conf:: icecover_env, py_embed +4::GridStat_fcstRTOFS_obsSMAP_climWOA_sss::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf:: icecover_env, py_embed #X::GridStat_fcstRTOFS_obsGHRSST_climWOA_sst::model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst.conf, model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsGHRSST_climWOA_sst/ci_overrides.conf:: icecover_env, py_embed diff --git a/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf new file mode 100644 index 0000000000..e47cae7aaf --- /dev/null +++ b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss.conf @@ -0,0 +1,267 @@ +# GridStat METplus Configuration + +# section heading for [config] variables - all items below this line and +# before the next section heading correspond to the [config] section +[config] + +# List of applications to run - only GridStat for this case +PROCESS_LIST = GridStat + +# time looping - options are INIT, VALID, RETRO, and REALTIME +# If set to INIT or RETRO: +# INIT_TIME_FMT, INIT_BEG, INIT_END, and INIT_INCREMENT must also be set +# If set to VALID or REALTIME: +# VALID_TIME_FMT, VALID_BEG, VALID_END, and VALID_INCREMENT must also be set +LOOP_BY = VALID + +# Format of INIT_BEG and INT_END using % items +# %Y = 4 digit year, %m = 2 digit month, %d = 2 digit day, etc. +# see www.strftime.org for more information +# %Y%m%d%H expands to YYYYMMDDHH +VALID_TIME_FMT = %Y%m%d + +# Start time for METplus run - must match INIT_TIME_FMT +VALID_BEG=20210502 + +# End time for METplus run - must match INIT_TIME_FMT +VALID_END=20210502 + +# Increment between METplus runs (in seconds if no units are specified) +# Must be >= 60 seconds +VALID_INCREMENT = 1M + +# List of forecast leads to process for each run time (init or valid) +# In hours if units are not specified +# If unset, defaults to 0 (don't loop through forecast leads) +LEAD_SEQ = 24 + + +# Order of loops to process data - Options are times, processes +# Not relevant if only one item is in the PROCESS_LIST +# times = run all wrappers in the PROCESS_LIST for a single run time, then +# increment the run time and run all wrappers again until all times have +# been evaluated. +# processes = run the first wrapper in the PROCESS_LIST for all times +# specified, then repeat for the next item in the PROCESS_LIST until all +# wrappers have been run +LOOP_ORDER = times + +# Verbosity of MET output - overrides LOG_VERBOSITY for GridStat only +LOG_GRID_STAT_VERBOSITY = 2 + +# Location of MET config file to pass to GridStat +GRID_STAT_CONFIG_FILE = {PARM_BASE}/met_config/GridStatConfig_wrapped + +# grid to remap data. Value is set as the 'to_grid' variable in the 'regrid' dictionary +# See MET User's Guide for more information +GRID_STAT_REGRID_TO_GRID = NONE + +#GRID_STAT_INTERP_FIELD = +#GRID_STAT_INTERP_VLD_THRESH = +#GRID_STAT_INTERP_SHAPE = +#GRID_STAT_INTERP_TYPE_METHOD = +#GRID_STAT_INTERP_TYPE_WIDTH = + +#GRID_STAT_NC_PAIRS_VAR_NAME = + +#GRID_STAT_CLIMO_MEAN_TIME_INTERP_METHOD = +#GRID_STAT_CLIMO_STDEV_TIME_INTERP_METHOD = + +#GRID_STAT_GRID_WEIGHT_FLAG = AREA + +# Name to identify model (forecast) data in output +MODEL = RTOFS + +# Name to identify observation data in output +OBTYPE = SMAP + +# set the desc value in the GridStat MET config file +GRID_STAT_DESC = NA + +# List of variables to compare in GridStat - FCST_VAR1 variables correspond +# to OBS_VAR1 variables +# Note [FCST/OBS/BOTH]_GRID_STAT_VAR_NAME can be used instead if different evaluations +# are needed for different tools + +# Name of forecast variable 1 +FCST_VAR1_NAME = {CONFIG_DIR}/read_rtofs_smap_woa.py {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/{init?fmt=%Y%m%d}_rtofs_glo_2ds_f024_prog.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/SMAP-L3-GLOB_{valid?fmt=%Y%m%d?shift=86400}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/OSTIA-UKMO-L4-GLOB-v2.0_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss {valid?fmt=%Y%m%d} fcst + +# List of levels to evaluate for forecast variable 1 +# A03 = 3 hour accumulation in GRIB file +FCST_VAR1_LEVELS = + +# List of thresholds to evaluate for each name/level combination for +# forecast variable 1 +FCST_VAR1_THRESH = + +#FCST_GRID_STAT_FILE_TYPE = + +# Name of observation variable 1 +OBS_VAR1_NAME = {CONFIG_DIR}/read_rtofs_smap_woa.py {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/{init?fmt=%Y%m%d}_rtofs_glo_2ds_f024_prog.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/SMAP-L3-GLOB_{valid?fmt=%Y%m%d?shift=86400}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/OSTIA-UKMO-L4-GLOB-v2.0_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss {valid?fmt=%Y%m%d} obs + + +# List of levels to evaluate for observation variable 1 +# (*,*) is NetCDF notation - must include quotes around these values! +# must be the same length as FCST_VAR1_LEVELS +OBS_VAR1_LEVELS = + +# List of thresholds to evaluate for each name/level combination for +# observation variable 1 +OBS_VAR1_THRESH = + +#GRID_STAT_MET_CONFIG_OVERRIDES = cat_thresh = [>=0.15]; +#BOTH_VAR1_THRESH = >=0.15 + +#OBS_GRID_STAT_FILE_TYPE = + + +# Name of climatology variable 1 +GRID_STAT_CLIMO_MEAN_FIELD = {name="{CONFIG_DIR}/read_rtofs_smap_woa.py {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/{init?fmt=%Y%m%d}_rtofs_glo_2ds_f024_prog.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/SMAP-L3-GLOB_{valid?fmt=%Y%m%d?shift=86400}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/OSTIA-UKMO-L4-GLOB-v2.0_{valid?fmt=%Y%m%d}.nc {INPUT_BASE}/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss {valid?fmt=%Y%m%d} climo"; level="(*,*)";} + + +# Time relative to valid time (in seconds) to allow files to be considered +# valid. Set both BEGIN and END to 0 to require the exact time in the filename +# Not used in this example. +FCST_GRID_STAT_FILE_WINDOW_BEGIN = 0 +FCST_GRID_STAT_FILE_WINDOW_END = 0 +OBS_GRID_STAT_FILE_WINDOW_BEGIN = 0 +OBS_GRID_STAT_FILE_WINDOW_END = 0 + +# MET GridStat neighborhood values +# See the MET User's Guide GridStat section for more information + +# width value passed to nbrhd dictionary in the MET config file +GRID_STAT_NEIGHBORHOOD_WIDTH = 1 + +# shape value passed to nbrhd dictionary in the MET config file +GRID_STAT_NEIGHBORHOOD_SHAPE = SQUARE + +# cov thresh list passed to nbrhd dictionary in the MET config file +GRID_STAT_NEIGHBORHOOD_COV_THRESH = >=0.5 + +# Set to true to run GridStat separately for each field specified +# Set to false to create one run of GridStat per run time that +# includes all fields specified. +GRID_STAT_ONCE_PER_FIELD = False + +# Set to true if forecast data is probabilistic +FCST_IS_PROB = false + +# Only used if FCST_IS_PROB is true - sets probabilistic threshold +FCST_GRID_STAT_PROB_THRESH = ==0.1 + +# Set to true if observation data is probabilistic +# Only used if configuring forecast data as the 'OBS' input +OBS_IS_PROB = false + +# Only used if OBS_IS_PROB is true - sets probabilistic threshold +OBS_GRID_STAT_PROB_THRESH = ==0.1 + +GRID_STAT_OUTPUT_PREFIX = SSS + +#GRID_STAT_CLIMO_MEAN_FILE_NAME = +#GRID_STAT_CLIMO_MEAN_FIELD = +#GRID_STAT_CLIMO_MEAN_REGRID_METHOD = +#GRID_STAT_CLIMO_MEAN_REGRID_WIDTH = +#GRID_STAT_CLIMO_MEAN_REGRID_VLD_THRESH = +#GRID_STAT_CLIMO_MEAN_REGRID_SHAPE = +#GRID_STAT_CLIMO_MEAN_TIME_INTERP_METHOD = +#GRID_STAT_CLIMO_MEAN_MATCH_MONTH = +#GRID_STAT_CLIMO_MEAN_DAY_INTERVAL = +#GRID_STAT_CLIMO_MEAN_HOUR_INTERVAL = + +#GRID_STAT_CLIMO_STDEV_FILE_NAME = +#GRID_STAT_CLIMO_STDEV_FIELD = +#GRID_STAT_CLIMO_STDEV_REGRID_METHOD = +#GRID_STAT_CLIMO_STDEV_REGRID_WIDTH = +#GRID_STAT_CLIMO_STDEV_REGRID_VLD_THRESH = +#GRID_STAT_CLIMO_STDEV_REGRID_SHAPE = +#GRID_STAT_CLIMO_STDEV_TIME_INTERP_METHOD = +#GRID_STAT_CLIMO_STDEV_MATCH_MONTH = +#GRID_STAT_CLIMO_STDEV_DAY_INTERVAL = +#GRID_STAT_CLIMO_STDEV_HOUR_INTERVAL = + + +#GRID_STAT_CLIMO_CDF_BINS = 1 +#GRID_STAT_CLIMO_CDF_CENTER_BINS = False +#GRID_STAT_CLIMO_CDF_WRITE_BINS = True + +#GRID_STAT_OUTPUT_FLAG_FHO = NONE +#GRID_STAT_OUTPUT_FLAG_CTC = NONE +#GRID_STAT_OUTPUT_FLAG_CTS = NONE +#GRID_STAT_OUTPUT_FLAG_MCTC = NONE +#GRID_STAT_OUTPUT_FLAG_MCTS = NONE +GRID_STAT_OUTPUT_FLAG_CNT = BOTH +#GRID_STAT_OUTPUT_FLAG_SL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_SAL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_VL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_VAL1L2 = NONE +#GRID_STAT_OUTPUT_FLAG_VCNT = NONE +#GRID_STAT_OUTPUT_FLAG_PCT = NONE +#GRID_STAT_OUTPUT_FLAG_PSTD = NONE +#GRID_STAT_OUTPUT_FLAG_PJC = NONE +#GRID_STAT_OUTPUT_FLAG_PRC = NONE +#GRID_STAT_OUTPUT_FLAG_ECLV = BOTH +#GRID_STAT_OUTPUT_FLAG_NBRCTC = NONE +#GRID_STAT_OUTPUT_FLAG_NBRCTS = NONE +#GRID_STAT_OUTPUT_FLAG_NBRCNT = NONE +#GRID_STAT_OUTPUT_FLAG_GRAD = BOTH +#GRID_STAT_OUTPUT_FLAG_DMAP = NONE + +#GRID_STAT_NC_PAIRS_FLAG_LATLON = FALSE +#GRID_STAT_NC_PAIRS_FLAG_RAW = FALSE +#GRID_STAT_NC_PAIRS_FLAG_DIFF = FALSE +#GRID_STAT_NC_PAIRS_FLAG_CLIMO = FALSE +#GRID_STAT_NC_PAIRS_FLAG_CLIMO_CDP = FALSE +#GRID_STAT_NC_PAIRS_FLAG_WEIGHT = FALSE +#GRID_STAT_NC_PAIRS_FLAG_NBRHD = FALSE +#GRID_STAT_NC_PAIRS_FLAG_FOURIER = FALSE +#GRID_STAT_NC_PAIRS_FLAG_GRADIENT = FALSE +#GRID_STAT_NC_PAIRS_FLAG_DISTANCE_MAP = FALSE +#GRID_STAT_NC_PAIRS_FLAG_APPLY_MASK = FALSE + + +# End of [config] section and start of [dir] section +[dir] +#use case configuration file directory +CONFIG_DIR = {PARM_BASE}/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss +# directory containing forecast input to GridStat +FCST_GRID_STAT_INPUT_DIR = + +# directory containing observation input to GridStat +OBS_GRID_STAT_INPUT_DIR = + +# directory containing climatology mean input to GridStat +# Not used in this example +GRID_STAT_CLIMO_MEAN_INPUT_DIR = + +# directory containing climatology mean input to GridStat +# Not used in this example +GRID_STAT_CLIMO_STDEV_INPUT_DIR = + +# directory to write output from GridStat +GRID_STAT_OUTPUT_DIR = {OUTPUT_BASE} + +# End of [dir] section and start of [filename_templates] section +[filename_templates] + +# Template to look for forecast input to GridStat relative to FCST_GRID_STAT_INPUT_DIR +FCST_GRID_STAT_INPUT_TEMPLATE = PYTHON_NUMPY + +# Template to look for observation input to GridStat relative to OBS_GRID_STAT_INPUT_DIR +OBS_GRID_STAT_INPUT_TEMPLATE = PYTHON_NUMPY + +# Optional subdirectories relative to GRID_STAT_OUTPUT_DIR to write output from GridStat +GRID_STAT_OUTPUT_TEMPLATE = {valid?fmt=%Y%m%d} + +# Template to look for climatology input to GridStat relative to GRID_STAT_CLIMO_MEAN_INPUT_DIR +# Not used in this example +GRID_STAT_CLIMO_MEAN_INPUT_TEMPLATE = PYTHON_NUMPY + +# Template to look for climatology input to GridStat relative to GRID_STAT_CLIMO_STDEV_INPUT_DIR +# Not used in this exampls +GRID_STAT_CLIMO_STDEV_INPUT_TEMPLATE = + +# Used to specify one or more verification mask files for GridStat +# Not used for this example +GRID_STAT_VERIFICATION_MASK_TEMPLATE = diff --git a/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/read_rtofs_smap_woa.py b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/read_rtofs_smap_woa.py new file mode 100644 index 0000000000..b5a0c3f19d --- /dev/null +++ b/parm/use_cases/model_applications/marine_and_cryosphere/GridStat_fcstRTOFS_obsSMAP_climWOA_sss/read_rtofs_smap_woa.py @@ -0,0 +1,346 @@ +#!/bin/env python +""" +Code adapted from +Todd Spindler +NOAA/NWS/NCEP/EMC +Designed to read in RTOFS,SMAP,WOA and OSTIA data +and based on user input, read sss data +and pass back in memory the forecast, observation, or climatology +data field +""" + +import numpy as np +import xarray as xr +import pandas as pd +import pyresample as pyr +from pandas.tseries.offsets import DateOffset +from datetime import datetime, timedelta +from sklearn.metrics import mean_squared_error +import io +from glob import glob +import warnings +import os, sys + + +if len(sys.argv) < 6: + print("Must specify the following elements: fcst_file obs_file ice_file, climo_file, valid_date, file_flag") + sys.exit(1) + +rtofsfile = os.path.expandvars(sys.argv[1]) +sssfile = os.path.expandvars(sys.argv[2]) +icefile = os.path.expandvars(sys.argv[3]) +climoDir = os.path.expandvars(sys.argv[4]) +vDate=datetime.strptime(sys.argv[5],'%Y%m%d') +file_flag = sys.argv[6] + +print('Starting Satellite SMAP V&V at',datetime.now(),'for',vDate, ' file_flag:',file_flag) + +pd.date_range(vDate,vDate) +platform='SMAP' +param='sss' + + +##################################################################### +# READ SMAP data ################################################## +##################################################################### + +if not os.path.exists(sssfile): + print('missing SMAP file for',vDate) + +sss_data=xr.open_dataset(sssfile,decode_times=True) +sss_data['time']=sss_data.time-pd.Timedelta('12H') # shift 12Z offset time to 00Z +sss_data2=sss_data['sss'].astype('single') +print('Retrieved SMAP data from NESDIS for',sss_data2.time.values) +#sss_data2=sss_data2.rename({'longitude':'lon','latitude':'lat'}) + + +# all coords need to be single precision +sss_data2['lon']=sss_data2.lon.astype('single') +sss_data2['lat']=sss_data2.lat.astype('single') +sss_data2.attrs['platform']=platform +sss_data2.attrs['units']='PSU' + +##################################################################### +# READ RTOFS data (model output in Tri-polar coordinates) ########### +##################################################################### + +print('reading rtofs ice') +if not os.path.exists(rtofsfile): + print('missing rtofs file',rtofsfile) + sys.exit(1) + +indata=xr.open_dataset(rtofsfile,decode_times=True) + + +indata=indata.mean(dim='MT') +indata = indata[param][:-1,] +indata.coords['time']=vDate +#indata.coords['fcst']=fcst + +outdata=indata.copy() + +outdata=outdata.rename({'Longitude':'lon','Latitude':'lat',}) +# all coords need to be single precision +outdata['lon']=outdata.lon.astype('single') +outdata['lat']=outdata.lat.astype('single') +outdata.attrs['platform']='rtofs '+platform + +##################################################################### +# READ CLIMO WOA data - May require 2 files depending on the date ### +##################################################################### + +if not os.path.exists(climoDir): + print('missing climo file file for',vDate) + +vDate=pd.Timestamp(vDate) + +climofile="woa13_decav_s{:02n}_04v2.nc".format(vDate.month) +climo_data=xr.open_dataset(climoDir+'/'+climofile,decode_times=False) +climo_data=climo_data['s_an'].squeeze()[0,] + +if vDate.day==15: # even for Feb, just because + climofile="woa13_decav_s{:02n}_04v2.nc".format(vDate.month) + climo_data=xr.open_dataset(climoDir+'/'+climofile,decode_times=False) + climo_data=climo_data['s_an'].squeeze()[0,] # surface only +else: + if vDate.day < 15: + start=vDate - DateOffset(months=1,day=15) + stop=pd.Timestamp(vDate.year,vDate.month,15) + else: + start=pd.Timestamp(vDate.year,vDate.month,15) + stop=vDate + DateOffset(months=1,day=15) + left=(vDate-start)/(stop-start) + + climofile1="woa13_decav_s{:02n}_04v2.nc".format(start.month) + climofile2="woa13_decav_s{:02n}_04v2.nc".format(stop.month) + climo_data1=xr.open_dataset(climoDir+'/'+climofile1,decode_times=False) + climo_data2=xr.open_dataset(climoDir+'/'+climofile2,decode_times=False) + climo_data1=climo_data1['s_an'].squeeze()[0,] # surface only + climo_data2=climo_data2['s_an'].squeeze()[0,] # surface only + + print('climofile1 :', climofile1) + print('climofile2 :', climofile2) + climo_data=climo_data1+((climo_data2-climo_data1)*left) + climofile='weighted average of '+climofile1+' and '+climofile2 + +# all coords need to be single precision +climo_data['lon']=climo_data.lon.astype('single') +climo_data['lat']=climo_data.lat.astype('single') +climo_data.attrs['platform']='woa' +climo_data.attrs['filename']=climofile + +##################################################################### +# READ ICE data for masking ######################################### +##################################################################### + +if not os.path.exists(icefile): + print('missing OSTIA ice file for',vDate) + +ice_data=xr.open_dataset(icefile,decode_times=True) +ice_data=ice_data.rename({'sea_ice_fraction':'ice'}) + +# all coords need to be single precision +ice_data2=ice_data.ice.astype('single') +ice_data2['lon']=ice_data2.lon.astype('single') +ice_data2['lat']=ice_data2.lat.astype('single') + + +def regrid(model,obs): + """ + regrid data to obs -- this assumes DataArrays + """ + model2=model.copy() + model2_lon=model2.lon.values + model2_lat=model2.lat.values + model2_data=model2.to_masked_array() + if model2_lon.ndim==1: + model2_lon,model2_lat=np.meshgrid(model2_lon,model2_lat) + + obs2=obs.copy() + obs2_lon=obs2.lon.astype('single').values + obs2_lat=obs2.lat.astype('single').values + obs2_data=obs2.astype('single').to_masked_array() + if obs2.lon.ndim==1: + obs2_lon,obs2_lat=np.meshgrid(obs2.lon.values,obs2.lat.values) + + model2_lon1=pyr.utils.wrap_longitudes(model2_lon) + model2_lat1=model2_lat.copy() + obs2_lon1=pyr.utils.wrap_longitudes(obs2_lon) + obs2_lat1=obs2_lat.copy() + + # pyresample gausssian-weighted kd-tree interp + # define the grids + orig_def = pyr.geometry.GridDefinition(lons=model2_lon1,lats=model2_lat1) + targ_def = pyr.geometry.GridDefinition(lons=obs2_lon1,lats=obs2_lat1) + radius=50000 + sigmas=25000 + model2_data2=pyr.kd_tree.resample_gauss(orig_def,model2_data,targ_def, + radius_of_influence=radius, + sigmas=sigmas, + fill_value=None) + model=xr.DataArray(model2_data2,coords=[obs.lat.values,obs.lon.values],dims=['lat','lon']) + + return model + +def expand_grid(data): + """ + concatenate global data for edge wraps + """ + + data2=data.copy() + data2['lon']=data2.lon+360 + data3=xr.concat((data,data2),dim='lon') + return data3 + +sss_data2=sss_data2.squeeze() + +print('regridding climo to obs') +climo_data=climo_data.squeeze() +climo_data=regrid(climo_data,sss_data2) + +print('regridding ice to obs') +ice_data2=regrid(ice_data2,sss_data2) + +print('regridding model to obs') +model2=regrid(outdata,sss_data2) + +# combine obs ice mask with ncep +obs2=sss_data2.to_masked_array() +ice2=ice_data2.to_masked_array() +climo2=climo_data.to_masked_array() +model2=model2.to_masked_array() + +#reconcile with obs +obs2.mask=np.ma.mask_or(obs2.mask,ice2>0.0) +obs2.mask=np.ma.mask_or(obs2.mask,climo2.mask) +obs2.mask=np.ma.mask_or(obs2.mask,model2.mask) +climo2.mask=obs2.mask +model2.mask=obs2.mask + +obs2=xr.DataArray(obs2,coords=[sss_data2.lat.values,sss_data2.lon.values], dims=['lat','lon']) +model2=xr.DataArray(model2,coords=[sss_data2.lat.values,sss_data2.lon.values], dims=['lat','lon']) +climo2=xr.DataArray(climo2,coords=[sss_data2.lat.values,sss_data2.lon.values], dims=['lat','lon']) + +model2=expand_grid(model2) +climo2=expand_grid(climo2) +obs2=expand_grid(obs2) + +#Create the MET grids based on the file_flag +if file_flag == 'fcst': + met_data = model2[:,:] + #trim the lat/lon grids so they match the data fields + lat_met = model2.lat + lon_met = model2.lon + print(" RTOFS Data shape: "+repr(met_data.shape)) + v_str = vDate.strftime("%Y%m%d") + v_str = v_str + '_000000' + lat_ll = float(lat_met.min()) + lon_ll = float(lon_met.min()) + n_lat = lat_met.shape[0] + n_lon = lon_met.shape[0] + delta_lat = (float(lat_met.max()) - float(lat_met.min()))/float(n_lat) + delta_lon = (float(lon_met.max()) - float(lon_met.min()))/float(n_lon) + print(f"variables:" + f"lat_ll: {lat_ll} lon_ll: {lon_ll} n_lat: {n_lat} n_lon: {n_lon} delta_lat: {delta_lat} delta_lon: {delta_lon}") + met_data.attrs = { + 'valid': v_str, + 'init': v_str, + 'lead': "00", + 'accum': "00", + 'name': 'sss', + 'standard_name': 'sea_surface_salinity', + 'long_name': 'sea_surface_salinity', + 'level': "SURFACE", + 'units': "psu", + + 'grid': { + 'type': "LatLon", + 'name': "RTOFS Grid", + 'lat_ll': lat_ll, + 'lon_ll': lon_ll, + 'delta_lat': delta_lat, + 'delta_lon': delta_lon, + 'Nlat': n_lat, + 'Nlon': n_lon, + } + } + attrs = met_data.attrs + +if file_flag == 'obs': + met_data = obs2[:,:] + #trim the lat/lon grids so they match the data fields + lat_met = obs2.lat + lon_met = obs2.lon + v_str = vDate.strftime("%Y%m%d") + v_str = v_str + '_000000' + lat_ll = float(lat_met.min()) + lon_ll = float(lon_met.min()) + n_lat = lat_met.shape[0] + n_lon = lon_met.shape[0] + delta_lat = (float(lat_met.max()) - float(lat_met.min()))/float(n_lat) + delta_lon = (float(lon_met.max()) - float(lon_met.min()))/float(n_lon) + print(f"variables:" + f"lat_ll: {lat_ll} lon_ll: {lon_ll} n_lat: {n_lat} n_lon: {n_lon} delta_lat: {delta_lat} delta_lon: {delta_lon}") + met_data.attrs = { + 'valid': v_str, + 'init': v_str, + 'lead': "00", + 'accum': "00", + 'name': 'sss', + 'standard_name': 'analyzed sea surface salinity', + 'long_name': 'sea_surface_salinity', + 'level': "SURFACE", + 'units': "psu", + + 'grid': { + 'type': "LatLon", + 'name': "Lat Lon", + 'lat_ll': lat_ll, + 'lon_ll': lon_ll, + 'delta_lat': delta_lat, + 'delta_lon': delta_lon, + 'Nlat': n_lat, + 'Nlon': n_lon, + } + } + attrs = met_data.attrs + +if file_flag == 'climo': + met_data = climo2[:,:] + #modify the lat and lon grids since they need to match the data dimensions, and code cuts the last row/column of data + lat_met = climo2.lat + lon_met = climo2.lon + v_str = vDate.strftime("%Y%m%d") + v_str = v_str + '_000000' + lat_ll = float(lat_met.min()) + lon_ll = float(lon_met.min()) + n_lat = lat_met.shape[0] + n_lon = lon_met.shape[0] + delta_lat = (float(lat_met.max()) - float(lat_met.min()))/float(n_lat) + delta_lon = (float(lon_met.max()) - float(lon_met.min()))/float(n_lon) + print(f"variables:" + f"lat_ll: {lat_ll} lon_ll: {lon_ll} n_lat: {n_lat} n_lon: {n_lon} delta_lat: {delta_lat} delta_lon: {delta_lon}") + met_data.attrs = { + 'valid': v_str, + 'init': v_str, + 'lead': "00", + 'accum': "00", + 'name': 'sea_water_salinity', + 'standard_name': 'sea_water_salinity', + 'long_name': 'sea_water_salinity', + 'level': "SURFACE", + 'units': "psu", + + 'grid': { + 'type': "LatLon", + 'name': "crs Grid", + 'lat_ll': lat_ll, + 'lon_ll': lon_ll, + 'delta_lat': delta_lat, + 'delta_lon': delta_lon, + 'Nlat': n_lat, + 'Nlon': n_lon, + } + } + attrs = met_data.attrs + From 50e54ccb93d1c580ff00fe8fc373f186bc245301 Mon Sep 17 00:00:00 2001 From: j-opatz Date: Wed, 19 Jan 2022 16:00:45 -0700 Subject: [PATCH 42/43] updated marine_and_cryo grouping --- .github/parm/use_case_groups.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 39eac94582..96f44cd91c 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -61,12 +61,7 @@ }, { "category": "marine_and_cryosphere", - "index_list": "3", - "run": false - }, - { - "category": "marine_and_cryosphere", - "index_list": "4", + "index_list": "3-4", "run": false }, { From 77d954dfd705def5f992f0310428852b575f09d6 Mon Sep 17 00:00:00 2001 From: Hank Fisher Date: Fri, 21 Jan 2022 12:06:27 -0700 Subject: [PATCH 43/43] Feature 1230 stratosphere metrics (#1354) * Initial checkin for Meridial Mean use case * Changed the name and directories * Added some documentation * Fixed config variables * Issue 1230 Stratospheric metrics (zonal/meridional use case) put INPUT_BASE in the user_env_vars so the user doesn't need to set the INPUT_BASE environment in the current working shell * Issue #1230 remove import of metplotpy-this isn't called * Issue #1230_stratosphere_metric Add use case to the list of use cases to be run for testing * Issue 1230 Remove the INPUT_BASE from the user_env_vars section * Issue #1230 redundant files * Issue #1230 redundant files * Issue #1230 redundant files * Issue #120 replace INPUT_BASE with INPUT_FILE_NAME * Issue #1230 remove entry for INPUT_FILE_NAME, this goes in the system.conf * Update all_use_cases.txt Copy and pasted #11 from s2s use case for the Stratosphere use case but forgot to update the index to 12. * Update use_case_groups.json added use case #12 from s2s to test stratosphere use case * Issue #1230 put INPUT_FILE_NAME back under the user_env_vars * Issue #1230 forgot to include the filename * Issue #1230 clean up config file, remove uneccessary comments, group related entries in the user_env_vars * Issue #1230 remove extraneous and incorrect path to the input_filename setting * Removed pingouin dependency * Issue #1230 another cut and paste error fixed for the Stratosphere use case * Issue #1230 type in name, obs_Only should be obsOnly * Issue #1230 added the metdatadb to the env, code imports metdatadb and may require some of these dependencies * issue #1230 removed pingouin dependency from comment to reduce any confusion * issue #1230 turn off the test for the Stratosphere metrics use case * Added use case image Co-authored-by: Hank Fisher Co-authored-by: Minna Win Co-authored-by: bikegeek <3753118+bikegeek@users.noreply.github.com> Co-authored-by: Christina Kalb --- .github/parm/use_case_groups.json | 5 + ci/docker/docker_env/scripts/metplotpy_env.sh | 2 - docs/_static/s2s-zonal_means.png | Bin 0 -> 26841 bytes .../UserScript_obsERA_obsOnly_Stratosphere.py | 137 ++++++++++++++++++ internal_tests/use_cases/all_use_cases.txt | 1 + ...serScript_obsERA_obsOnly_Stratosphere.conf | 56 +++++++ .../README | 9 ++ .../meridonial_mean.py | 82 +++++++++++ .../meridonial_mean.yaml | 2 + 9 files changed, 292 insertions(+), 2 deletions(-) create mode 100755 docs/_static/s2s-zonal_means.png create mode 100644 docs/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere.py create mode 100644 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere.conf create mode 100644 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/README create mode 100755 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.py create mode 100644 parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.yaml diff --git a/.github/parm/use_case_groups.json b/.github/parm/use_case_groups.json index 96f44cd91c..85959b3fb9 100644 --- a/.github/parm/use_case_groups.json +++ b/.github/parm/use_case_groups.json @@ -154,6 +154,11 @@ "index_list": "11", "run": false }, + { + "category": "s2s", + "index_list": "12", + "run": false + }, { "category": "space_weather", "index_list": "0-1", diff --git a/ci/docker/docker_env/scripts/metplotpy_env.sh b/ci/docker/docker_env/scripts/metplotpy_env.sh index 1db44769ee..914c1b074b 100755 --- a/ci/docker/docker_env/scripts/metplotpy_env.sh +++ b/ci/docker/docker_env/scripts/metplotpy_env.sh @@ -10,7 +10,6 @@ # matplotlib==3.3.0 # scipy==1.5.1 # plotly==4.9.0 -# pingouin==0.3.8 # cartopy==0.18.0 # eofs==1.3.0 # cmocean==2.0 @@ -34,7 +33,6 @@ conda create -y --clone ${BASE_ENV} --name ${ENV_NAME} conda install -y --name ${ENV_NAME} -c conda-forge matplotlib==3.3.0 conda install -y --name ${ENV_NAME} -c conda-forge scipy==1.5.1 conda install -y --name ${ENV_NAME} -c conda-forge plotly==4.9.0 -conda install -y --name ${ENV_NAME} -c conda-forge pingouin==0.3.8 conda install -y --name ${ENV_NAME} -c conda-forge cartopy==0.18.0 conda install -y --name ${ENV_NAME} -c conda-forge eofs==1.3.0 conda install -y --name ${ENV_NAME} -c conda-forge cmocean==2.0 diff --git a/docs/_static/s2s-zonal_means.png b/docs/_static/s2s-zonal_means.png new file mode 100755 index 0000000000000000000000000000000000000000..3e5ab96dc491306227d6acb7dc71062a5734858b GIT binary patch literal 26841 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

6iwz1ux-INc2FaXv}R>^It)Q&Pnu93|+ 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 d@Tg9&*e)xucMQ6/METdatadb:/METcalcpy + +The file SSWC_v1.0_varFull_ERAi_d20130106_s20121107_e20130307_c20160701.nc needs to be +on disk somewhere on your computer and referenced correctly in the file +meridial_mean.yaml diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.py b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.py new file mode 100755 index 0000000000..777be36ea6 --- /dev/null +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +""" +Create meridonial mean statistics + +""" +import os +import sys +import logging +import yaml +import xarray as xr # http://xarray.pydata.org/ +import metcalcpy.util.read_env_vars_in_config as readconfig +import metcalcpy.pre_processing.directional_means as directional_means +import METreadnc.util.read_netcdf as read_netcdf + + +def main(): + """ + Use existing default meridonial mean config file found in METcalcpy to + grab the test file + """ + + + """ + Read Meridial Mean YAML configuration file + user can use their own, if none specified at the command line, + use the "default" example YAML config file, spectra_plot_coh2.py + Using a custom YAML reader so we can use environment variables + """ + + try: + input_config_file = os.getenv("YAML_CONFIG_NAME","meridonial_mean.yaml") + config = readconfig.parse_config(input_config_file) + logging.info(config) + except yaml.YAMLError as exc: + logging.error(exc) + + """ + Read METplus config file paramaters + """ + #input_file_name = os.environ.get("INPUT_FILE_NAME","SSWC_v1.0_varFull_ERAi_d20130106_s20121107_e20130307_c20160701.nc") + input_file = config["input_filename"] + + """ + Setup logging + """ + logfile = "meridonial_mean.log" + logging_level = os.environ.get("LOG_LEVEL","logging.INFO") + logging.basicConfig(stream=logfile, level=logging_level) + + """ + Read dataset + """ + try: + logging.info('Opening ' + input_file[0]) + file_reader = read_netcdf.ReadNetCDF() + + #file_reader returns a list of xarrays even if there is only one file requested to be read + #so we change it from a list to a single + ds = file_reader.read_into_xarray(input_file)[0] + except IOError as exc: + logging.error('Unable to open ' + input_file) + logging.error(exc) + sys.exit(1) + logging.debug(ds) + ds = ds[['uwndFull_TS','vwndFull_TS','tempFull_TS','geopFull_TS']] + ds = ds.rename({'timeEv60':'time', + 'lat':'latitude', # pyzome currently expects dimensions named latitude and longitude + 'lon':'longitude', + 'uwndFull_TS':'u', + 'vwndFull_TS':'v', + 'tempFull_TS':'T', + 'geopFull_TS':'Z'}) + + uzm = directional_means.zonal_mean(ds.u) + Tzm = directional_means.zonal_mean(ds.T) + T_6090 = directional_means.meridional_mean(Tzm, 60, 90) + + print(T_6090) + +if __name__ == '__main__': + main() diff --git a/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.yaml b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.yaml new file mode 100644 index 0000000000..6cadc5160b --- /dev/null +++ b/parm/use_cases/model_applications/s2s/UserScript_obsERA_obsOnly_Stratosphere/meridonial_mean.yaml @@ -0,0 +1,2 @@ +input_filename: +- !ENV '${INPUT_FILE_NAME}'