diff --git a/act/plotting/plot.py b/act/plotting/plot.py index 0fb0bbd627..4d7e3a8a8e 100644 --- a/act/plotting/plot.py +++ b/act/plotting/plot.py @@ -230,7 +230,8 @@ def assign_to_figure_axis(self, fig, ax): self.fig = fig self.axes = np.array([ax]) - def add_colorbar(self, mappable, title=None, subplot_index=(0,)): + def add_colorbar(self, mappable, title=None, subplot_index=(0,), pad=None, + width=None, **kwargs): """ Adds a colorbar to the plot. @@ -241,7 +242,13 @@ def add_colorbar(self, mappable, title=None, subplot_index=(0,)): title : str The title of the colorbar. Set to None to have no title. subplot_index : 1 or 2D tuple, list, or array - The index of the subplot to set the x range of. + The index of the subplot to set the x range + pad : float + Padding to right of plot for placement of the colorbar + width : float + Width of the colorbar + **kwargs : keyword arguments + The keyword arguments for :func:`plt.colorbar` Returns ------- @@ -255,12 +262,18 @@ def add_colorbar(self, mappable, title=None, subplot_index=(0,)): fig = self.fig ax = self.axes[subplot_index] + if pad is None: + pad = 0.01 + + if width is None: + width = 0.01 + # Give the colorbar it's own axis so the 2D plots line up with 1D box = ax.get_position() - pad, width = 0.01, 0.01 cax = fig.add_axes([box.xmax + pad, box.ymin, width, box.height]) - cbar = plt.colorbar(mappable, cax=cax) - cbar.ax.set_ylabel(title, rotation=270, fontsize=8, labelpad=3) + cbar = plt.colorbar(mappable, cax=cax, **kwargs) + if title is not None: + cbar.ax.set_ylabel(title, rotation=270, fontsize=8, labelpad=3) cbar.ax.tick_params(labelsize=6) self.cbs.append(cbar) diff --git a/act/plotting/timeseriesdisplay.py b/act/plotting/timeseriesdisplay.py index 634f1b282e..2fe787bcec 100644 --- a/act/plotting/timeseriesdisplay.py +++ b/act/plotting/timeseriesdisplay.py @@ -7,12 +7,14 @@ import warnings from copy import deepcopy from re import search, search as re_search +import textwrap import matplotlib.dates as mdates import matplotlib.pyplot as plt import numpy as np import pandas as pd from matplotlib import colors as mplcolors +import matplotlib as mpl from mpl_toolkits.axes_grid1 import make_axes_locatable from scipy.interpolate import NearestNDInterpolator @@ -274,7 +276,10 @@ def plot( force_line_plot=False, labels=False, cbar_label=None, + cbar_h_adjust=None, secondary_y=False, + y_axis_flag_meanings=False, + colorbar_labels=None, **kwargs, ): """ @@ -343,8 +348,26 @@ def plot( number of lines plotted. cbar_label : str Option to overwrite default colorbar label. + cbar_h_adjust : float + Option to adjust location of colorbar horizontally. Positive values + move to right negative values move to left. secondary_y : boolean Option to plot on secondary y axis. + y_axis_flag_meanings : boolean or int + When set to True and plotting state variable with flag_values and + flag_meanings attribures will replace y axis numerical values + with flag_meanings value. Set to a positive number larger than 1 + to indicate maximum word length to use. If text is longer that the + value and has space characters will split text over multiple lines. + colorbar_labels : dict + A dictionary containing values for plotting a 2D array of state variables. + The dictionary uses data values as keys and a dictionary containing keys + 'text' and 'color' for each data value to plot. + Example: + {0: {'text': 'Clear sky', 'color': 'white'}, + 1: {'text': 'Liquid', 'color': 'green'}, + 2: {'text': 'Ice', 'color': 'blue'}, + 3: {'text': 'Mixed phase', 'color': 'purple'}} **kwargs : keyword arguments The keyword arguments for :func:`plt.plot` (1D timeseries) or :func:`plt.pcolormesh` (2D timeseries). @@ -364,6 +387,9 @@ def plot( elif dsname is None: dsname = list(self._obj.keys())[0] + if y_axis_flag_meanings: + kwargs['linestyle'] = '' + # Get data and dimensions data = self._obj[dsname][field] dim = list(self._obj[dsname][field].dims) @@ -415,6 +441,20 @@ def plot( else: ax = self.axes[subplot_index].twinx() + if colorbar_labels is not None: + flag_values = list(colorbar_labels.keys()) + flag_meanings = [value['text'] for key, value in colorbar_labels.items()] + cbar_colors = [value['color'] for key, value in colorbar_labels.items()] + cmap = mpl.colors.ListedColormap(cbar_colors) + for ii, flag_meaning in enumerate(flag_meanings): + if len(flag_meaning) > 20: + flag_meaning = textwrap.fill(flag_meaning, width=20) + flag_meanings[ii] = flag_meaning + else: + flag_values = None + flag_meanings = None + cbar_colors = None + if ydata is None: if day_night_background is True: self.day_night_background(subplot_index=subplot_index, dsname=dsname) @@ -492,6 +532,21 @@ def plot( elif add_legend: ax.legend() + # Change y axis to text from flag_meanings if requested. + if y_axis_flag_meanings: + flag_meanings = self._obj[dsname][field].attrs['flag_meanings'] + flag_values = self._obj[dsname][field].attrs['flag_values'] + # If keyword is larger than 1 assume this is the maximum character length + # desired and insert returns to wrap text. + if y_axis_flag_meanings > 1: + for ii, flag_meaning in enumerate(flag_meanings): + if len(flag_meaning) > y_axis_flag_meanings: + flag_meaning = textwrap.fill(flag_meaning, width=y_axis_flag_meanings) + flag_meanings[ii] = flag_meaning + + ax.set_yticks(flag_values) + ax.set_yticklabels(flag_meanings) + else: # Add in nans to ensure the data are not streaking if add_nan is True: @@ -535,7 +590,8 @@ def plot( ax.set_title(set_title) # Set YTitle - ax.set_ylabel(ytitle) + if not y_axis_flag_meanings: + ax.set_ylabel(ytitle) # Set X Limit - We want the same time axes for all subplots if not hasattr(self, 'time_rng'): @@ -610,13 +666,21 @@ def plot( if ydata is not None: if cbar_label is None: - self.add_colorbar(mesh, title=cbar_default, subplot_index=subplot_index) + cbar_title = cbar_default else: - self.add_colorbar( - mesh, - title=''.join(['(', cbar_label, ')']), - subplot_index=subplot_index, - ) + cbar_title = ''.join(['(', cbar_label, ')']) + + if colorbar_labels is not None: + cbar_title = None + cbar = self.add_colorbar(mesh, title=cbar_title, subplot_index=subplot_index, + values=flag_values, pad=cbar_h_adjust) + cbar.set_ticks(flag_values) + cbar.set_ticklabels(flag_meanings) + cbar.ax.tick_params(labelsize=10) + + else: + self.add_colorbar(mesh, title=cbar_title, subplot_index=subplot_index, + pad=cbar_h_adjust) return ax diff --git a/act/qc/clean.py b/act/qc/clean.py index 766b7aaa67..ec741c2cd7 100644 --- a/act/qc/clean.py +++ b/act/qc/clean.py @@ -433,6 +433,7 @@ def clean_arm_state_variables( override_cf_flag=True, clean_units_string=True, integer_flag=True, + replace_in_flag_meanings=None, ): """ Function to clean up state variables to use more CF style. @@ -449,6 +450,11 @@ def clean_arm_state_variables( udunits compliant '1'. integer_flag : bool Pass through keyword of 'flag' for get_attr_info(). + replace_in_flag_meanings : None or string + Character string to search and replace in each flag meanings array value + to increase readability since the flag_meanings stored in netCDF file + is a single character array separated by space character. Alows for + replacing things like "_" with space character. """ if isinstance(variables, str): @@ -456,31 +462,47 @@ def clean_arm_state_variables( for var in variables: flag_info = self.get_attr_info(variable=var, flag=integer_flag) - if flag_info is None: - continue + if flag_info is not None: - # Add new attributes to variable - for attr in ['flag_values', 'flag_meanings', 'flag_masks']: + # Add new attributes to variable + for attr in ['flag_values', 'flag_meanings', 'flag_masks']: - if len(flag_info[attr]) > 0: - # Only add if attribute does not exist. - if attr in self._obj[var].attrs.keys() is False: - self._obj[var].attrs[attr] = copy.copy(flag_info[attr]) - # If flag is set set attribure even if exists - elif override_cf_flag: - self._obj[var].attrs[attr] = copy.copy(flag_info[attr]) + if len(flag_info[attr]) > 0: + # Only add if attribute does not exist. + if attr in self._obj[var].attrs.keys() is False: + self._obj[var].attrs[attr] = copy.copy(flag_info[attr]) + # If flag is set, set attribure even if exists + elif override_cf_flag: + self._obj[var].attrs[attr] = copy.copy(flag_info[attr]) - # Remove replaced attributes - arm_attributes = flag_info['arm_attributes'] - for attr in arm_attributes: - try: - del self._obj[var].attrs[attr] - except KeyError: - pass + # Remove replaced attributes + arm_attributes = flag_info['arm_attributes'] + for attr in arm_attributes: + try: + del self._obj[var].attrs[attr] + except KeyError: + pass + + # Check if flag_meanings is string. If so convert to list. + try: + flag_meanings = copy.copy(self._obj[var].attrs['flag_meanings']) + if isinstance(flag_meanings, str): + flag_meanings = flag_meanings.split() + if replace_in_flag_meanings is not None: + for ii, flag_meaning in enumerate(flag_meanings): + flag_meaning = flag_meaning.replace(replace_in_flag_meanings, ' ') + flag_meanings[ii] = flag_meaning + + self._obj[var].attrs['flag_meanings'] = flag_meanings + except KeyError: + pass # Clean up units attribute from unitless to udunits '1' - if clean_units_string and self._obj[var].attrs['units'] == 'unitless': - self._obj[var].attrs['units'] = '1' + try: + if clean_units_string and self._obj[var].attrs['units'] == 'unitless': + self._obj[var].attrs['units'] = '1' + except KeyError: + pass def correct_valid_minmax(self, qc_variable): """ diff --git a/act/tests/baseline/test_colorbar_labels.png b/act/tests/baseline/test_colorbar_labels.png new file mode 100644 index 0000000000..72d51ba88c Binary files /dev/null and b/act/tests/baseline/test_colorbar_labels.png differ diff --git a/act/tests/baseline/test_y_axis_flag_meanings.png b/act/tests/baseline/test_y_axis_flag_meanings.png new file mode 100644 index 0000000000..42542dfd90 Binary files /dev/null and b/act/tests/baseline/test_y_axis_flag_meanings.png differ diff --git a/act/tests/data/nsacloudphaseC1.c1.20180601.000000.nc b/act/tests/data/nsacloudphaseC1.c1.20180601.000000.nc new file mode 100644 index 0000000000..05904da089 Binary files /dev/null and b/act/tests/data/nsacloudphaseC1.c1.20180601.000000.nc differ diff --git a/act/tests/sample_files.py b/act/tests/sample_files.py index 20b909dd18..825c5aadb4 100644 --- a/act/tests/sample_files.py +++ b/act/tests/sample_files.py @@ -57,3 +57,4 @@ EXAMPLE_HK = os.path.join( DATA_PATH, 'mosaossp2auxM1.00.20191217.010801.raw.20191216000000.hk') EXAMPLE_MET_YAML = os.path.join(DATA_PATH, 'sgpmetE13.b1.yaml') +EXAMPLE_CLOUDPHASE = os.path.join(DATA_PATH, 'nsacloudphaseC1.c1.20180601.000000.nc') diff --git a/act/tests/test_plotting.py b/act/tests/test_plotting.py index 56d31eaceb..0e50e061cc 100644 --- a/act/tests/test_plotting.py +++ b/act/tests/test_plotting.py @@ -943,3 +943,39 @@ def test_time_plot2(): display = TimeSeriesDisplay(obj) display.plot('time') return display.fig + + +@pytest.mark.mpl_image_compare(tolerance=30) +def test_y_axis_flag_meanings(): + variable = 'detection_status' + obj = arm.read_netcdf(sample_files.EXAMPLE_CEIL1, + keep_variables=[variable, 'lat', 'lon', 'alt']) + obj.clean.clean_arm_state_variables(variable, override_cf_flag=True) + + display = TimeSeriesDisplay(obj, figsize=(12, 8), subplot_shape=(1,)) + display.plot(variable, subplot_index=(0, ), day_night_background=True, y_axis_flag_meanings=18) + display.fig.subplots_adjust(left=0.15, right=0.95, bottom=0.1, top=0.94) + + return display.fig + + +@pytest.mark.mpl_image_compare(tolerance=35) +def test_colorbar_labels(): + variable = 'cloud_phase_hsrl' + obj = arm.read_netcdf(sample_files.EXAMPLE_CLOUDPHASE) + obj.clean.clean_arm_state_variables(variable) + + display = TimeSeriesDisplay(obj, figsize=(12, 8), subplot_shape=(1,)) + + y_axis_labels = {} + flag_colors = ['white', 'green', 'blue', 'red', 'cyan', 'orange', 'yellow', 'black', 'gray'] + for value, meaning, color in zip(obj[variable].attrs['flag_values'], + obj[variable].attrs['flag_meanings'], + flag_colors): + y_axis_labels[value] = {'text': meaning, 'color': color} + + display.plot(variable, subplot_index=(0, ), colorbar_labels=y_axis_labels, + cbar_h_adjust=0) + display.fig.subplots_adjust(left=0.08, right=0.88, bottom=0.1, top=0.94) + + return display.fig diff --git a/examples/plot_state_variable.py b/examples/plot_state_variable.py new file mode 100644 index 0000000000..d313289676 --- /dev/null +++ b/examples/plot_state_variable.py @@ -0,0 +1,85 @@ +""" +Plotting state variables +------------------------ + +Simple examples for plotting state variable using flag_values +and flag_meanings. + +Author: Ken Kehoe + +""" + +from act.io.armfiles import read_netcdf +from act.tests.sample_files import EXAMPLE_CEIL1, EXAMPLE_CLOUDPHASE +from act.plotting import TimeSeriesDisplay +from matplotlib import pyplot as plt + +# ---------------------------------------------------------------------- # +# This example will create a plot of the detection status time dimentioned +# varible and set the y axis to the string values defined in flag_meanings +# instead of plotting the flag values. +# ---------------------------------------------------------------------- # + +# Read in data to plot. Only read in the variables that will be used. +variable = 'detection_status' +obj = read_netcdf(EXAMPLE_CEIL1, + keep_variables=[variable, 'lat', 'lon', 'alt']) + +# Clean up the variable attributes to match the needed internal standard. +# Setting override_cf_flag allows the flag_meanings to be rewritten using +# the better formatted attribute values to make the plot more pretty. +obj.clean.clean_arm_state_variables(variable, override_cf_flag=True) + +# Creat Plot Display by setting figure size and number of plots +display = TimeSeriesDisplay(obj, figsize=(12, 8), subplot_shape=(1,)) + +# Plot the variable and indicate the day/night background should be added +# to the plot. +# Since the string length for each value is long we can ask to wrap the +# text to make a better looking plot by setting the number of characters +# to keep per line with the value set to y_axis_flag_meanings. If the +# strings were short we can just use y_axis_flag_meanings=True. +display.plot(variable, day_night_background=True, y_axis_flag_meanings=18) + +# Display plot in a new window +plt.show() + +# ----------------------------------------------------------------------- # +# This example will plot the 2 dimentional state variable indicating +# the cloud type classificaiton. The plot will use the correct formatting +# for x and y axis, but will show a colorbar explaining color for each value. +# ----------------------------------------------------------------------- # +# Read in data to plot. Only read in the variables that will be used. +variable = 'cloud_phase_hsrl' +obj = read_netcdf(EXAMPLE_CLOUDPHASE) + +# Clean up the variable attributes to match the needed internal standard. +obj.clean.clean_arm_state_variables(variable, override_cf_flag=True) + +# Creat Plot Display by setting figure size and number of plots +display = TimeSeriesDisplay(obj, figsize=(12, 8), subplot_shape=(1,)) + +# We need to pass in a dictionary containing text and color information +# for each value in the data variable. We will need to define what +# color we want plotted for each value but use the flag_values and +# flag_meanings attribute to supply the other needed information. +y_axis_labels = {} +flag_colors = ['white', 'green', 'blue', 'red', 'cyan', 'orange', 'yellow', 'black', 'gray'] +for value, meaning, color in zip(obj[variable].attrs['flag_values'], + obj[variable].attrs['flag_meanings'], + flag_colors): + y_axis_labels[value] = {'text': meaning, 'color': color} + +# Create plot and indicate the colorbar should use the defined colors +# by passing in dictionary to colorbar_lables. +# Also, since the test to display on the colorbar is longer than normal +# we can adjust the placement of the colorbar by indicating the adjustment +# of horizontal locaiton with cbar_h_adjust. +display.plot(variable, colorbar_labels=y_axis_labels, cbar_h_adjust=0) + +# To provide more room for colorbar and take up more of the defined +# figure, we can adjust the margins around the initial plot. +display.fig.subplots_adjust(left=0.08, right=0.88, bottom=0.1, top=0.94) + +# Display plot in a new window +plt.show()