# This software is open source software available under the BSD-3 license.
#
# Copyright (c) 2022 Triad National Security, LLC. All rights reserved.
# Copyright (c) 2022 Lawrence Livermore National Security, LLC. All rights
# reserved.
# Copyright (c) 2022 UT-Battelle, LLC. All rights reserved.
#
# Additional copyright and license information can be found in the LICENSE file
# distributed with this code, or at
# https://raw.githubusercontent.com/MPAS-Dev/MPAS-Analysis/master/LICENSE
import xarray as xr
import numpy as np
from string import capwords
from mpas_analysis.shared import AnalysisTask
from mpas_analysis.shared.plot import plot_global_comparison, \
plot_projection_comparison
from mpas_analysis.shared.html import write_image_xml
from mpas_analysis.shared.climatology import \
get_remapped_mpas_climatology_file_name
from mpas_analysis.shared.climatology.comparison_descriptors import \
get_comparison_descriptor
[docs]class PlotClimatologyMapSubtask(AnalysisTask):
"""
An analysis task for plotting 2D model fields against observations.
Attributes
----------
season : str
A season (key in ``shared.constants.monthDictionary``) to be
plotted.
comparisonGridName : str
The name of the comparison grid to plot.
remapMpasClimatologySubtask : mpas_analysis.shared.climatology.RemapMpasClimatologySubtask
The subtask for remapping the MPAS climatology that this subtask
will plot
remapObsClimatologySubtask : mpas_analysis.shared.climatology.RemapObservedClimatologySubtask
The subtask for remapping the observational climatology that this
subtask will plot
secondRemapMpasClimatologySubtask : mpas_analysis.shared.climatology.RemapMpasClimatologySubtask
A second subtask for remapping another MPAS climatology to plot
in the second panel and compare with in the third panel
removeMean : bool, optional
If True, a common mask for the model and reference data sets is
computed (where both are valid) and the mean over that mask is
subtracted from both the model and reference results. This is
useful for data sets where the desire is to compare the spatial
pattern but the mean offset is not meaningful (e.g. SSH)
outFileLabel : str
The prefix on each plot and associated XML file
fieldNameInTitle : str
The name of the field being plotted, as used in the plot title
mpasFieldName : str
The name of the variable in the MPAS timeSeriesStatsMonthly output
diffTitleLabel : str, optional
the title of the difference subplot
unitsLabel : str
the units of the plotted field, to be displayed on color bars
imageCaption : str
the caption when mousing over the plot or displaying it full
screen
galleryGroup : str
the name of the group of galleries in which this plot belongs
groupSubtitle : str or None
the subtitle of the group in which this plot belongs (or blank
if none)
groupLink : str
a short name (with no spaces) for the link to the gallery group
galleryName : str or None
the name of the gallery in which this plot belongs
depth : {None, float, 'top', 'bot'}
Depth at which to perform the comparison, 'top' for the surface
'bot' for the base
configSectionName : str
the name of the section where the color map and range is defined
maskMinThreshold : float or None
a value below which the field is mask out in plots
maskMaxThreshold : float or None
a value above which the field is mask out in plots
extend : {'neither', 'both', 'min', 'max'}
Determines the ``contourf``-coloring of values that are outside the
range of the levels provided if using an indexed colormap.
"""
[docs] def __init__(self, parentTask, season, comparisonGridName,
remapMpasClimatologySubtask, remapObsClimatologySubtask=None,
secondRemapMpasClimatologySubtask=None, controlConfig=None,
depth=None, removeMean=False, subtaskName=None):
"""
Construct one analysis subtask for each plot (i.e. each season and
comparison grid) and a subtask for computing climatologies.
Parameters
----------
parentTask : mpas_analysis.shared.AnalysisTask
The parent (master) task for this subtask
season : str
A season (key in ``shared.constants.monthDictionary``) to be
plotted.
comparisonGridName : str
The name of the comparison grid to plot.
remapMpasClimatologySubtask : mpas_analysis.shared.climatology.RemapMpasClimatologySubtask
The subtask for remapping the MPAS climatology that this subtask
will plot
remapObsClimatologySubtask : mpas_analysis.shared.climatology.RemapObservedClimatologySubtask, optional
The subtask for remapping the observational climatology that this
subtask will plot
secondRemapMpasClimatologySubtask : mpas_analysis.shared.climatology.RemapMpasClimatologySubtask, optional
A second subtask for remapping another MPAS climatology to plot
in the second panel and compare with in the third panel
controlConfig : mpas_tools.config.MpasConfigParser, optional
Configuration options for a control run (if any)
depth : {float, 'top', 'bot'}, optional
Depth the data is being plotted, 'top' for the sea surface
'bot' for the sea floor
removeMean : bool, optional
If True, a common mask for the model and reference data sets is
computed (where both are valid) and the mean over that mask is
subtracted from both the model and reference results. This is
useful for data sets where the desire is to compare the spatial
pattern but the mean offset is not meaningful (e.g. SSH)
subtaskName : str, optional
The name of the subtask. If not specified, it is
``plot<season>_<comparisonGridName>`` with a suffix indicating the
depth being sliced (if any)
"""
self.season = season
self.depth = depth
self.comparisonGridName = comparisonGridName
self.remapMpasClimatologySubtask = remapMpasClimatologySubtask
self.remapObsClimatologySubtask = remapObsClimatologySubtask
self.secondRemapMpasClimatologySubtask = \
secondRemapMpasClimatologySubtask
self.controlConfig = controlConfig
self.removeMean = removeMean
if depth is None:
self.depthSuffix = ''
else:
self.depthSuffix = f'depth_{depth}'
if subtaskName is None:
subtaskName = f'plot{season}_{comparisonGridName}'
if depth is not None:
subtaskName = f'{subtaskName}_{self.depthSuffix}'
config = parentTask.config
taskName = parentTask.taskName
tags = parentTask.tags
# call the constructor from the base class (AnalysisTask)
super(PlotClimatologyMapSubtask, self).__init__(
config=config, taskName=taskName, subtaskName=subtaskName,
componentName=parentTask.componentName, tags=tags)
# this task should not run until the remapping subtasks are done, since
# it relies on data from those subtasks
self.run_after(remapMpasClimatologySubtask)
if remapObsClimatologySubtask is not None:
self.run_after(remapObsClimatologySubtask)
if secondRemapMpasClimatologySubtask is not None:
self.run_after(secondRemapMpasClimatologySubtask)
self.outFileLabel = None
self.fieldNameInTitle = None
self.mpasFieldName = None
self.refFieldName = None
self.refTitleLabel = None
self.diffTitleLabel = None
self.unitsLabel = None
self.imageCaption = None
self.galleryGroup = None
self.groupSubtitle = None
self.groupLink = None
self.galleryName = None
self.configSectionName = None
self.thumbnailDescription = None
self.startYear = None
self.endYear = None
self.startDate = None
self.endDate = None
self.filePrefix = None
self.maskMinThreshold = None
self.maskMaxThreshold = None
self.extend = 'both'
[docs] def set_plot_info(self, outFileLabel, fieldNameInTitle, mpasFieldName,
refFieldName, refTitleLabel, unitsLabel,
imageCaption, galleryGroup, groupSubtitle, groupLink,
galleryName, diffTitleLabel='Model - Observations',
configSectionName=None, maskMinThreshold=None,
maskMaxThreshold=None, extend=None):
"""
Store attributes related to plots, plot file names and HTML output.
Parameters
----------
outFileLabel : str
The prefix on each plot and associated XML file
fieldNameInTitle : str
The name of the field being plotted, as used in the plot title
mpasFieldName : str
The name of the variable in the MPAS timeSeriesStatsMonthly output
refFieldName : str
The name of the variable to use from the observations or reference
file
refTitleLabel : str
the title of the observations or reference subplot
unitsLabel : str
the units of the plotted field, to be displayed on color bars
imageCaption : str
the caption when mousing over the plot or displaying it full
screen
galleryGroup : str
the name of the group of galleries in which this plot belongs
groupSubtitle : str or None
the subtitle of the group in which this plot belongs (or blank
if none)
groupLink : str
a short name (with no spaces) for the link to the gallery group
galleryName : str or None
the name of the gallery in which this plot belongs
diffTitleLabel : str, optional
the title of the difference subplot
configSectionName : str or None, optional
the name of the section where the color map and range is defined,
default is the name of the task
maskMinThreshold : float or None, optional
a value below which the field is mask out in plots
maskMaxThreshold : float or None, optional
a value above which the field is mask out in plots
extend : {'neither', 'both', 'min', 'max'}, optional
Determines the ``contourf``-coloring of values that are outside the
range of the levels provided if using an indexed colormap.
"""
self.outFileLabel = outFileLabel
self.fieldNameInTitle = fieldNameInTitle
self.mpasFieldName = mpasFieldName
self.refFieldName = refFieldName
self.refTitleLabel = refTitleLabel
self.diffTitleLabel = diffTitleLabel
self.unitsLabel = unitsLabel
# xml/html related variables
self.imageCaption = imageCaption
self.galleryGroup = galleryGroup
self.groupSubtitle = groupSubtitle
self.groupLink = groupLink
self.galleryName = galleryName
self.maskMinThreshold = maskMinThreshold
self.maskMaxThreshold = maskMaxThreshold
if configSectionName is None:
self.configSectionName = self.taskName
else:
self.configSectionName = configSectionName
season = self.season
depth = self.depth
if depth is None:
self.fieldNameInTitle = fieldNameInTitle
self.thumbnailDescription = season
elif depth == 'top':
self.fieldNameInTitle = f'Sea Surface {fieldNameInTitle}'
self.thumbnailDescription = f'{season} surface'
elif depth == 'bot':
self.fieldNameInTitle = f'Sea Floor {fieldNameInTitle}'
self.thumbnailDescription = f'{season} floor'
else:
self.fieldNameInTitle = f'{fieldNameInTitle} at z={depth} m'
self.thumbnailDescription = f'{season} z={depth} m'
if extend is not None:
self.extend = extend
def setup_and_check(self):
"""
Perform steps to set up the analysis and check for errors in the setup.
"""
# first, call setup_and_check from the base class (AnalysisTask),
# which will perform some common setup, including storing:
# self.runDirectory , self.historyDirectory, self.plotsDirectory,
# self.namelist, self.runStreams, self.historyStreams,
# self.calendar
super(PlotClimatologyMapSubtask, self).setup_and_check()
config = self.config
self.startYear = config.getint('climatology', 'startYear')
self.endYear = config.getint('climatology', 'endYear')
self.startDate = config.get('climatology', 'startDate')
self.endDate = config.get('climatology', 'endDate')
mainRunName = config.get('runs', 'mainRunName')
self.xmlFileNames = []
prefixPieces = [self.outFileLabel]
if self.comparisonGridName != 'latlon':
prefixPieces.append(self.comparisonGridName)
prefixPieces.append(mainRunName)
if self.depth is not None:
prefixPieces.append(self.depthSuffix)
years = f'years{self.startYear:04d}-{self.endYear:04d}'
prefixPieces.extend([self.season, years])
self.filePrefix = '_'.join(prefixPieces)
self.xmlFileNames.append(
f'{self.plotsDirectory}/{self.filePrefix}.xml')
def run_task(self):
"""
Plots a comparison of E3SM/MPAS output to SST/TEMP, SSS/SALT or MLD
observations or a control run
"""
season = self.season
depth = self.depth
comparisonGridName = self.comparisonGridName
self.logger.info(
f"\nPlotting 2-d maps of {self.fieldNameInTitle} climatologies "
f"for {season} on the {comparisonGridName} grid...")
# first read the model climatology
remappedFileName = \
self.remapMpasClimatologySubtask.get_remapped_file_name(
season=season, comparisonGridName=comparisonGridName)
remappedModelClimatology = xr.open_dataset(remappedFileName)
if depth is not None:
if str(depth) not in remappedModelClimatology.depthSlice.values:
raise KeyError(f'The climatology you are attempting to '
f'perform depth slices of was originally '
f'created without depth {depth}. You will need '
f'to delete and regenerate the climatology')
remappedModelClimatology = remappedModelClimatology.sel(
depthSlice=str(depth), drop=True)
# now the observations or control run
if self.remapObsClimatologySubtask is not None:
remappedFileName = self.remapObsClimatologySubtask.get_file_name(
stage='remapped', season=season,
comparisonGridName=comparisonGridName)
remappedRefClimatology = xr.open_dataset(remappedFileName)
elif self.secondRemapMpasClimatologySubtask is not None:
remappedFileName = \
self.secondRemapMpasClimatologySubtask.get_remapped_file_name(
season=season, comparisonGridName=comparisonGridName)
remappedRefClimatology = xr.open_dataset(remappedFileName)
elif self.controlConfig is not None:
climatologyName = self.remapMpasClimatologySubtask.climatologyName
remappedFileName = \
get_remapped_mpas_climatology_file_name(
self.controlConfig, season=season,
componentName=self.componentName,
climatologyName=climatologyName,
comparisonGridName=comparisonGridName,
op=self.remapMpasClimatologySubtask.op)
remappedRefClimatology = xr.open_dataset(remappedFileName)
controlStartYear = self.controlConfig.getint('climatology',
'startYear')
controlEndYear = self.controlConfig.getint('climatology',
'endYear')
if controlStartYear != self.startYear or \
controlEndYear != self.endYear:
self.refTitleLabel = \
f'{self.refTitleLabel}\n' \
f'(years {controlStartYear:04d}-{controlEndYear:04d})'
else:
remappedRefClimatology = None
if remappedRefClimatology is not None and depth is not None:
depthIndex = -1
for index, depthSlice in enumerate(
remappedRefClimatology.depthSlice.values):
try:
depthSlice = depthSlice.decode("utf-8")
except AttributeError:
pass
if depthSlice == str(depth):
depthIndex = index
if depthIndex == -1:
raise KeyError(f'The climatology you are attempting to '
f'perform depth slices of was originally '
f'created without depth {depth}. You will need '
f'to delete and regenerate the climatology')
remappedRefClimatology = remappedRefClimatology.isel(
depthSlice=depthIndex, drop=True)
if self.removeMean:
if remappedRefClimatology is None:
remappedModelClimatology[self.mpasFieldName] = \
remappedModelClimatology[self.mpasFieldName] - \
remappedModelClimatology[self.mpasFieldName].mean()
else:
masked = remappedModelClimatology[self.mpasFieldName].where(
remappedRefClimatology[self.refFieldName].notnull())
remappedModelClimatology[self.mpasFieldName] = \
remappedModelClimatology[self.mpasFieldName] - \
masked.mean()
masked = remappedRefClimatology[self.refFieldName].where(
remappedModelClimatology[self.mpasFieldName].notnull())
remappedRefClimatology[self.refFieldName] = \
remappedRefClimatology[self.refFieldName] - \
masked.mean()
if self.componentName == 'ocean':
componentName = 'Ocean'
componentSubdirectory = 'ocean'
elif self.componentName == 'seaIce':
componentName = 'Sea Ice'
componentSubdirectory = 'sea_ice'
else:
raise ValueError(f'Unexpected component: {self.componentName}')
if self.comparisonGridName == 'latlon':
self._plot_latlon(remappedModelClimatology, remappedRefClimatology,
componentName, componentSubdirectory)
else:
self._plot_projection(remappedModelClimatology,
remappedRefClimatology,
componentName, componentSubdirectory)
def _plot_latlon(self, remappedModelClimatology, remappedRefClimatology,
componentName, componentSubdirectory):
""" plotting a global lat-lon data set """
season = self.season
config = self.config
configSectionName = self.configSectionName
mainRunName = config.get('runs', 'mainRunName')
modelOutput = _nans_to_numpy_mask(
remappedModelClimatology[self.mpasFieldName].values)
lon = remappedModelClimatology['lon'].values
lat = remappedModelClimatology['lat'].values
lonTarg, latTarg = np.meshgrid(lon, lat)
if remappedRefClimatology is None:
refOutput = None
bias = None
else:
refOutput = _nans_to_numpy_mask(
remappedRefClimatology[self.refFieldName].values)
bias = modelOutput - refOutput
# mask with thresholds only after taking the diff
refOutput = self._mask_with_thresholds(refOutput)
modelOutput = self._mask_with_thresholds(modelOutput)
if config.has_option(configSectionName, 'titleFontSize'):
titleFontSize = config.getint(configSectionName, 'titleFontSize')
else:
titleFontSize = None
if config.has_option(configSectionName, 'defaultFontSize'):
defaultFontSize = config.getint(configSectionName,
'defaultFontSize')
else:
defaultFontSize = None
filePrefix = self.filePrefix
outFileName = f'{self.plotsDirectory}/{filePrefix}.png'
title = f'{self.fieldNameInTitle} ({season}, years ' \
f'{self.startYear:04d}-{self.endYear:04d})'
plot_global_comparison(config,
lonTarg,
latTarg,
modelOutput,
refOutput,
bias,
configSectionName,
fileout=outFileName,
title=title,
modelTitle=mainRunName,
refTitle=self.refTitleLabel,
diffTitle=self.diffTitleLabel,
cbarlabel=self.unitsLabel,
titleFontSize=titleFontSize,
defaultFontSize=defaultFontSize,
extend=self.extend)
caption = f'{season} {self.imageCaption}'
write_image_xml(
config,
filePrefix,
componentName=componentName,
componentSubdirectory=componentSubdirectory,
galleryGroup=f'Global {self.galleryGroup}',
groupSubtitle=self.groupSubtitle,
groupLink=f'global_{self.groupLink}',
gallery=self.galleryName,
thumbnailDescription=self.thumbnailDescription,
imageDescription=caption,
imageCaption=caption)
def _plot_projection(self, remappedModelClimatology,
remappedRefClimatology,
componentName, componentSubdirectory):
""" plotting a dataset on a projection grid """
season = self.season
comparisonGridName = self.comparisonGridName
config = self.config
configSectionName = self.configSectionName
mainRunName = config.get('runs', 'mainRunName')
validMask = remappedModelClimatology['validMask'].values
landMask = np.ma.masked_array(
np.ones(validMask.shape),
mask=np.logical_not(np.isnan(validMask)))
modelOutput = _nans_to_numpy_mask(
remappedModelClimatology[self.mpasFieldName].values)
if remappedRefClimatology is None:
refOutput = None
bias = None
else:
refOutput = _nans_to_numpy_mask(
remappedRefClimatology[self.refFieldName].values)
bias = modelOutput - refOutput
# mask with maskValue only after taking the diff
refOutput = self._mask_with_thresholds(refOutput)
modelOutput = self._mask_with_thresholds(modelOutput)
comparisonDescriptor = get_comparison_descriptor(
config, comparisonGridName)
x = comparisonDescriptor.xCorner
y = comparisonDescriptor.yCorner
aspectRatio = (x[-1] - x[0])/(y[-1] - y[0])
# if the plots are even a bit wider than they are tall, make them
# vertical
vertical = aspectRatio > 1.2
filePrefix = self.filePrefix
outFileName = f'{self.plotsDirectory}/{filePrefix}.png'
title = f'{self.fieldNameInTitle} ({season}, years ' \
f'{self.startYear:04d}-{self.endYear:04d})'
if config.has_option(configSectionName, 'titleFontSize'):
titleFontSize = config.getint(configSectionName, 'titleFontSize')
else:
titleFontSize = None
if config.has_option(configSectionName, 'defaultFontSize'):
defaultFontSize = config.getint(configSectionName,
'defaultFontSize')
else:
defaultFontSize = None
if config.has_option(configSectionName, 'cartopyGridFontSize'):
cartopyGridFontSize = config.getint(configSectionName,
'cartopyGridFontSize')
else:
cartopyGridFontSize = None
plot_projection_comparison(
config,
x,
y,
landMask,
modelOutput,
refOutput,
bias,
fileout=outFileName,
colorMapSectionName=configSectionName,
projectionName=comparisonGridName,
title=title,
modelTitle=mainRunName,
refTitle=self.refTitleLabel,
diffTitle=self.diffTitleLabel,
cbarlabel=self.unitsLabel,
titleFontSize=titleFontSize,
cartopyGridFontSize=cartopyGridFontSize,
defaultFontSize=defaultFontSize,
vertical=vertical,
extend=self.extend)
upperGridName = capwords(comparisonGridName.replace('_', ' '))
caption = f'{season} {self.imageCaption}'
write_image_xml(
config,
filePrefix,
componentName=componentName,
componentSubdirectory=componentSubdirectory,
galleryGroup=f'{upperGridName} {self.galleryGroup}',
groupSubtitle=self.groupSubtitle,
groupLink=f'{comparisonGridName}_{self.groupLink}',
gallery=self.galleryName,
thumbnailDescription=self.thumbnailDescription,
imageDescription=caption,
imageCaption=caption)
def _mask_with_thresholds(self, field):
if self.maskMinThreshold is not None or \
self.maskMaxThreshold is not None:
mask = field.mask
if self.maskMinThreshold is not None:
mask = np.logical_or(mask, field <= self.maskMinThreshold)
if self.maskMaxThreshold is not None:
mask = np.logical_or(mask, field >= self.maskMaxThreshold)
field = np.ma.masked_array(field, mask)
return field
def _nans_to_numpy_mask(field):
"""
Convert a numpy array with NaNs to a masked numpy array
"""
field = np.ma.masked_array(
field, np.isnan(field))
return field