# -*- coding: utf-8 -*-
# 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/main/LICENSE
#
import datetime
import xarray as xr
import pandas as pd
import numpy as np
from scipy import signal, stats
import matplotlib.pyplot as plt
from mpas_analysis.shared.climatology import climatology
from mpas_analysis.shared.constants import constants
from mpas_analysis.shared.io.utility import build_config_full_path, \
build_obs_path
from mpas_analysis.shared.timekeeping.utility import datetime_to_days, \
string_to_days_since_date, string_to_datetime
from mpas_analysis.shared.timekeeping.MpasRelativeDelta import \
MpasRelativeDelta
from mpas_analysis.shared.io import open_mpas_dataset
from mpas_analysis.shared.plot.ticks import plot_xtick_format
from mpas_analysis.shared.plot.save import savefig
from mpas_analysis.shared import AnalysisTask
from mpas_analysis.shared.html import write_image_xml
[docs]
class IndexNino34(AnalysisTask):
"""
A task for computing and plotting time series and spectra of the El Nino
3.4 climate index
Attributes
----------
mpasTimeSeriesTask : ``MpasTimeSeriesTask``
The task that extracts the time series from MPAS monthly output
controlconfig : mpas_tools.config.MpasConfigParser
Configuration options for a control run (if any)
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
[docs]
def __init__(self, config, mpasTimeSeriesTask, controlConfig=None):
"""
Construct the analysis task.
Parameters
----------
config : mpas_tools.config.MpasConfigParser
Configuration options
mpasTimeSeriesTask : ``MpasTimeSeriesTask``
The task that extracts the time series from MPAS monthly output
controlconfig : mpas_tools.config.MpasConfigParser, optional
Configuration options for a control run (if any)
"""
# Authors
# -------
# Xylar Asay-Davis
# first, call the constructor from the base class (AnalysisTask)
super(IndexNino34, self).__init__(
config=config,
taskName='indexNino34',
componentName='ocean',
tags=['timeSeries', 'index', 'nino', 'publicObs'])
self.mpasTimeSeriesTask = mpasTimeSeriesTask
self.controlConfig = controlConfig
self.run_after(mpasTimeSeriesTask)
def setup_and_check(self):
"""
Perform steps to set up the analysis and check for errors in the setup.
"""
# Authors
# -------
# Xylar Asay-Davis
# 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(IndexNino34, self).setup_and_check()
startDate = self.config.get('index', 'startDate')
endDate = self.config.get('index', 'endDate')
delta = MpasRelativeDelta(string_to_datetime(endDate),
string_to_datetime(startDate),
calendar=self.calendar)
months = delta.months + 12*delta.years
if months <= 12:
raise ValueError('Cannot meaninfully analyze El Nino climate '
'index because the time series is too short.')
self.variableList = \
['timeMonthly_avg_avgValueWithinOceanRegion_avgSurfaceTemperature']
self.mpasTimeSeriesTask.add_variables(variableList=self.variableList)
self.inputFile = self.mpasTimeSeriesTask.outputFile
mainRunName = self.config.get('runs', 'mainRunName')
config = self.config
regionToPlot = config.get('indexNino34', 'region')
if regionToPlot not in ['nino3.4', 'nino3', 'nino4']:
raise ValueError('Unexpectes El Nino Index region {}'.format(
regionToPlot))
ninoIndexNumber = regionToPlot[4:]
self.xmlFileNames = []
for filePrefix in ['nino{}_{}'.format(ninoIndexNumber, mainRunName),
'nino{}_spectra_{}'.format(ninoIndexNumber,
mainRunName)]:
self.xmlFileNames.append('{}/{}.xml'.format(self.plotsDirectory,
filePrefix))
def run_task(self):
"""
Computes NINO34 index and plots the time series and power spectrum with
95 and 99% confidence bounds
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
config = self.config
calendar = self.calendar
regionToPlot = config.get('indexNino34', 'region')
ninoIndexNumber = regionToPlot[4:]
self.logger.info("\nPlotting El Nino {} Index time series and power "
"spectrum....".format(ninoIndexNumber))
self.logger.info(' Load SST data...')
fieldName = 'nino'
startDate = self.config.get('index', 'startDate')
endDate = self.config.get('index', 'endDate')
startYear = self.config.getint('index', 'startYear')
endYear = self.config.getint('index', 'endYear')
dataSource = config.get('indexNino34', 'observationData')
observationsDirectory = build_obs_path(
config, 'ocean', '{}Subdirectory'.format(fieldName))
# specify obsTitle based on data path
# These are the only data sets supported
if dataSource == 'HADIsst':
dataPath = "{}/HADIsst_nino34_20180710.nc".format(
observationsDirectory)
obsTitle = 'HADSST'
refDate = '1870-01-01'
elif dataSource == 'ERS_SSTv4':
dataPath = "{}/ERS_SSTv4_nino34_20180710.nc".format(
observationsDirectory)
obsTitle = 'ERS SSTv4'
refDate = '1800-01-01'
else:
raise ValueError('Bad value for config option observationData {} '
'in [indexNino34] section.'.format(dataSource))
mainRunName = config.get('runs', 'mainRunName')
# regionIndex should correspond to NINO34 in surface weighted Average
# AM
regions = config.getexpression('regions', 'regions')
regionToPlot = config.get('indexNino34', 'region')
regionIndex = regions.index(regionToPlot)
# Load data:
ds = open_mpas_dataset(fileName=self.inputFile,
calendar=calendar,
variableList=self.variableList,
startDate=startDate,
endDate=endDate)
# Observations have been processed to the nino34Index prior to reading
dsObs = xr.open_dataset(dataPath, decode_cf=False, decode_times=False)
# add the days between 0001-01-01 and the refDate so we have a new
# reference date of 0001-01-01 (like for the model Time)
dsObs["Time"] = dsObs.Time + \
string_to_days_since_date(dateString=refDate, calendar=calendar)
nino34Obs = dsObs.sst
self.logger.info(' Compute El Nino {} Index...'.format(
ninoIndexNumber))
varName = self.variableList[0]
regionSST = ds[varName].isel(nOceanRegions=regionIndex)
nino34Main = self._compute_nino34_index(regionSST, calendar)
# Compute the observational index over the entire time range
# nino34Obs = compute_nino34_index(dsObs.sst, calendar)
self.logger.info(' Computing El Nino {} power spectra...'.format(
ninoIndexNumber))
spectraMain = self._compute_nino34_spectra(nino34Main)
# Compute the observational spectra over the whole record
spectraObs = self._compute_nino34_spectra(nino34Obs)
# Compute the observational spectra over the last 30 years for
# comparison. Only saving the spectra
subsetEndYear = 2016
if self.controlConfig is None:
subsetStartYear = 1976
else:
# make the subset the same length as the input data set
subsetStartYear = subsetEndYear - (endYear - startYear)
time_start = datetime_to_days(datetime.datetime(subsetStartYear, 1, 1),
calendar=calendar)
time_end = datetime_to_days(datetime.datetime(subsetEndYear, 12, 31),
calendar=calendar)
nino34Subset = nino34Obs.sel(Time=slice(time_start, time_end))
spectraSubset = self._compute_nino34_spectra(nino34Subset)
if self.controlConfig is None:
nino34s = [nino34Obs[2:-3], nino34Subset, nino34Main[2:-3]]
titles = ['{} (Full Record)'.format(obsTitle),
'{} ({} - {})'.format(obsTitle, subsetStartYear,
subsetEndYear),
mainRunName]
spectra = [spectraObs, spectraSubset, spectraMain]
else:
baseDirectory = build_config_full_path(
self.controlConfig, 'output', 'timeSeriesSubdirectory')
refFileName = '{}/{}.nc'.format(
baseDirectory, self.mpasTimeSeriesTask.fullTaskName)
dsRef = open_mpas_dataset(
fileName=refFileName,
calendar=calendar,
variableList=self.variableList)
regionSSTRef = dsRef[varName].isel(nOceanRegions=regionIndex)
nino34Ref = self._compute_nino34_index(regionSSTRef, calendar)
nino34s = [nino34Subset, nino34Main[2:-3], nino34Ref[2:-3]]
controlRunName = self.controlConfig.get('runs', 'mainRunName')
spectraRef = self._compute_nino34_spectra(nino34Ref)
titles = ['{} ({} - {})'.format(obsTitle, subsetStartYear,
subsetEndYear),
mainRunName,
'Control: {}'.format(controlRunName)]
spectra = [spectraSubset, spectraMain, spectraRef]
# Convert frequencies to period in years
for s in spectra:
s['period'] = \
1.0 / (constants.eps + s['f'] * constants.sec_per_year)
self.logger.info(' Plot El Nino {} index and spectra...'.format(
ninoIndexNumber))
outFileName = '{}/nino{}_{}.png'.format(self.plotsDirectory,
ninoIndexNumber, mainRunName)
self._nino34_timeseries_plot(
nino34s=nino34s,
title=u'El Niño {} Index'.format(ninoIndexNumber),
panelTitles=titles,
outFileName=outFileName)
self._write_xml(filePrefix='nino{}_{}'.format(ninoIndexNumber,
mainRunName),
plotType='Time Series',
ninoIndexNumber=ninoIndexNumber)
outFileName = '{}/nino{}_spectra_{}.png'.format(self.plotsDirectory,
ninoIndexNumber,
mainRunName)
self._nino34_spectra_plot(
spectra=spectra,
title=u'El Niño {} power spectrum'.format(ninoIndexNumber),
panelTitles=titles,
outFileName=outFileName)
self._write_xml(filePrefix='nino{}_spectra_{}'.format(ninoIndexNumber,
mainRunName),
plotType='Spectra',
ninoIndexNumber=ninoIndexNumber)
def _compute_nino34_index(self, regionSST, calendar):
"""
Computes nino34 index time series. It follow the standard nino34
algorithm, i.e.,
1. Compute monthly average SST in the region
2. Computes anomalous SST
3. Performs a 5 month running mean over the anomalies
This routine requires regionSST to be the SSTs in the nino3.4 region
ONLY. It is defined as lat > -5S and lat < 5N and lon > 190E and
lon < 240E.
Parameters
----------
regionSST : xarray.DataArray object
values of SST in the nino region
calendar: {'gregorian', 'noleap'}
The name of the calendars used in the MPAS run
Returns
-------
xarray.DataArray object containing the nino34index
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
if not isinstance(regionSST, xr.core.dataarray.DataArray):
raise ValueError('regionSST should be an xarray DataArray')
# add 'month' data array so we can group by month below.
regionSST = climatology.add_years_months_days_in_month(regionSST,
calendar)
# Compute monthly average and anomaly of climatology of SST
monthlyClimatology = \
climatology.compute_monthly_climatology(regionSST,
maskVaries=False)
anomaly = regionSST.groupby('month') - monthlyClimatology
# Remove the long term trend from the anomalies
detrendedAnomal = signal.detrend(anomaly.values)
anomaly.values = detrendedAnomal
# Compute 5 month running mean
wgts = np.ones(5) / 5.
return self._running_mean(anomaly, wgts)
def _compute_nino34_spectra(self, nino34Index):
"""
Computes power spectra of Nino34 index.
nino34Index is the NINO index computed by compute_nino34_index
The algorithm follows the NCL cvdp package see
http://www.cesm.ucar.edu/working_groups/CVC/cvdp/code.html
Parameters
----------
nino34Index : xarray.DataArray object
nino34Index for analysis
Returns
-------
pxxSmooth : xarray.DataArray object
nino34Index power spectra that has been smoothed with a modified
Daniell window (https://www.ncl.ucar.edu/Document/Functions/Built-in/specx_anal.shtml)
f : numpy.array
array of frequencies corresponding to the center of the spectral
bins resulting from the analysis
mkov*scale : numpy.array
Red noise fit to pxxSmooth
mkov*scale*xLow : numpy.array
95% confidence threshold from chi-squared test
mkov*scale*xHigh : numpy.array
99% confidence threshold from chi-squared test
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
# Move nino34Index to numpy to allow functionality with scipy routines
ninoIndex = nino34Index.values
window = signal.tukey(len(ninoIndex), alpha=0.1)
f, Pxx = signal.periodogram(window * ninoIndex,
1.0 / constants.sec_per_month)
# computes power spectra, smoothed with a weighted running mean
nwts = max(1, int(7 * len(ninoIndex) / 1200))
# verify window length is odd, if not, add 1
if nwts % 2 == 0:
nwts += 1
# Calculate the weights for the running mean
# Weights are from the modified Daniell Window
wgts = np.ones(nwts)
wgts[0] = 0.5
wgts[-1] = 0.5
wgts /= sum(wgts)
pxxSmooth = (self._running_mean(pd.Series(Pxx), wgts) /
constants.sec_per_month)
# compute 99 and 95% confidence intervals and red-noise process
# Uses Chi squared test
r = self._autocorr(ninoIndex)[0, 1]
r2 = 2. * r
rsq = r**2
# In the temp2 variable, f is converted to give wavenumber, i.e.
# 0,1,2,...,N/2
temp2 = r2 * np.cos(2. * np.pi * f * constants.sec_per_month)
mkov = 1. / (1. + rsq - temp2)
sum1 = np.sum(mkov)
sum2 = np.sum(pxxSmooth.values)
scale = sum2 / sum1
df = 2. / (constants.tapcoef * sum(wgts**2))
xLow = stats.chi2.interval(0.95, df)[1] / df
xHigh = stats.chi2.interval(0.99, df)[1] / df
# return Spectra, 99% confidence level, 95% confidence level,
# and Red-noise fit
spectra = {'f': f, 'spectrum': pxxSmooth,
'conf99': mkov * scale * xHigh,
'conf95': mkov * scale * xLow,
'redNoise': mkov * scale}
return spectra
def _autocorr(self, x, t=1):
"""
Computes lag one auto-correlation for the NINO34 spectra calculation
Parameters
----------
x : numpy 1-D array
time series array
Returns
-------
Single value giving the lag one auto-correlation
If t != 1, this is no longer a lag one auto-correlation
"""
# Authors
# -------
# Luke Van Roekel
return np.corrcoef(np.array([x[0:len(x) - t], x[t:len(x)]]))
def _running_mean(self, inputData, wgts):
"""
Calculates a generic weighted running mean
Parameters
----------
inputData : xr.DataArray
Data to be smoothed
wgts : numpy.array
array of weights that give the smoothing type
for the nino index this is a 5-point boxcar window
for the nino power spectra this is a modified Daniell window (see
https://www.ncl.ucar.edu/Document/Functions/Built-in/specx_anal.shtml)
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
nt = len(inputData)
sp = (len(wgts) - 1) // 2
runningMean = inputData.copy()
for k in range(sp, nt - (sp + 1)):
runningMean[k] = sum(wgts * inputData[k - sp:k + sp + 1].values)
return runningMean
def _nino34_spectra_plot(self, spectra, title, panelTitles,
outFileName, lineWidth=2, xlabel='Period (years)',
ylabel=r'Power ($^o$C / cycles mo$^{-1}$)',
titleFontSize=None, figsize=(9, 21), dpi=None,
periodMin=1., periodMax=10.):
"""
Plots the nino34 time series and power spectra in an image file
Parameters
----------
spectra : list of dict
a dictionary for each panel returned from
``self._compute_nino34_spectra`` including entries
``period`` (periods to plot on x-axis), ``spectrum`` (nino34 power
spectra), ``conf95`` (95% confidence level based on chi squared
test), ``conf99`` (99% confidence level based on chi squared test)
and ``redNoise`` (red noise fit to ``spectrum``)
title : str
the title of the plot
panelTitles : list of str
title of each panel of the plot
outFileName : str
the file name to be written
lineWidth : int, optional
control line width
xLabel, yLabel : str, optional
axis labels
titleFontSize : int, optional
the size of the title font
figsize : tuple of float, optional
the size of the figure in inches
dpi : int, optional
the number of dots per inch of the figure, taken from section
``plot`` option ``dpi`` in the config file by default
periodMin, periodMax : float, optional
the maximum and minimum periods (in years) to be plotted
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
config = self.config
if dpi is None:
dpi = config.getint('plot', 'dpi')
fig = plt.figure(figsize=figsize, dpi=dpi)
if titleFontSize is None:
titleFontSize = config.get('plot', 'titleFontSize')
axis_font = {'size': config.get('plot', 'axisFontSize')}
title_font = {'size': titleFontSize,
'color': config.get('plot', 'titleFontColor'),
'weight': config.get('plot', 'titleFontWeight')}
if title is not None:
fig.suptitle(title, y=0.92, **title_font)
spectrumNames = ['spectrum', 'redNoise', 'conf95', 'conf99']
colors = ['k', 'r', 'g', 'b']
legends = ['Nino34 spectrum', 'Red noise fit',
'95% confidence threshold', '99% confidence threshold']
maxYval = -1e20
for plotIndex in range(3):
x = spectra[plotIndex]['period']
ys = [spectra[plotIndex][spectrumNames[curveIndex]] for curveIndex
in range(4)]
maxYval = max(maxYval,
self._plot_size_y_axis(x=x, ys=ys, xmin=periodMin,
xmax=periodMax))
for plotIndex in range(3):
plt.subplot(3, 1, plotIndex + 1)
period = spectra[plotIndex]['period']
for curveIndex in range(4):
spectrum = spectra[plotIndex][spectrumNames[curveIndex]]
plt.plot(period[2:-3], spectrum[2:-3], colors[curveIndex],
linewidth=lineWidth, label=legends[curveIndex])
plt.xlim(10, 1)
plt.legend(loc='upper right')
plt.ylim(0, 0.9 * maxYval)
if panelTitles[plotIndex] is not None:
plt.title(panelTitles[plotIndex], **title_font)
if xlabel is not None:
plt.xlabel(xlabel, **axis_font)
if ylabel is not None:
plt.ylabel(ylabel, **axis_font)
plt.tight_layout(rect=[0, 0.03, 1, 0.90])
if outFileName is not None:
savefig(outFileName, config)
plt.close()
def _nino34_timeseries_plot(self, nino34s, title, panelTitles, outFileName,
xlabel='Time (years)', ylabel=r'($\degree$C)',
titleFontSize=None, figsize=(9, 21), dpi=None,
maxXTicks=20, lineWidth=2):
"""
Plots the nino34 time series and power spectra in an image file
Parameters
----------
nino34s : list of xarray.dataArray
nino34 timeseries to plot in each panel
title : str
the title of the plot
panelTitles : list of str
title of each panel of the plot
outFileName : str
the file name to be written
xLabel, yLabel : str
axis labels
titleFontSize : int, optional
the size of the title font
figsize : tuple of float, optional
the size of the figure in inches
dpi : int, optional
the number of dots per inch of the figure, taken from section
``plot`` option ``dpi`` in the config file by default
lineWidth : int, optional
control line width
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
config = self.config
calendar = self.calendar
if dpi is None:
dpi = config.getint('plot', 'dpi')
fig = plt.figure(figsize=figsize, dpi=dpi)
if titleFontSize is None:
titleFontSize = config.get('plot', 'titleFontSize')
axis_font = {'size': config.get('plot', 'axisFontSize')}
title_font = {'size': titleFontSize,
'color': config.get('plot', 'titleFontColor'),
'weight': config.get('plot', 'titleFontWeight')}
if title is not None:
fig.suptitle(title, y=0.92, **title_font)
for plotIndex in range(3):
plt.subplot(3, 1, plotIndex + 1)
index = nino34s[plotIndex].values
time = nino34s[plotIndex].Time.values
self._plot_nino_timeseries(index, time, xlabel, ylabel,
panelTitles[plotIndex],
title_font, axis_font, lineWidth)
minDays = time.min()
maxDays = time.max()
plot_xtick_format(calendar, minDays, maxDays, maxXTicks)
plt.tight_layout(rect=[0, 0.03, 1, 0.90])
if outFileName is not None:
savefig(outFileName, config)
plt.close()
def _plot_nino_timeseries(self, ninoIndex, time, xlabel, ylabel,
panelTitle, title_font, axis_font,
lineWidth):
"""
Plot the nino time series on a subplot
Parameters
----------
ninoIndex : numpy.array
nino34 Index values (can be obs or model)
time : numpy.array
time values for the nino index
xlabel : string
string for x-axis label
ylabel : string
string for y-axis label
panelTitle : string
string to label the subplot with
lineWidth : list of str
control line width
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
plt.title(panelTitle, y=1.06, **title_font)
y1 = ninoIndex
nt = np.size(ninoIndex)
y2 = np.zeros(nt)
plt.plot(time, 0.4 * np.ones(nt), '--k',
linewidth=lineWidth)
plt.plot(time, -0.4 * np.ones(nt), '--k',
linewidth=lineWidth)
plt.fill_between(time, y1, y2, where=y1 > y2,
facecolor='red', interpolate=True, linewidth=0)
plt.fill_between(time, y1, y2, where=y1 < y2,
facecolor='blue', interpolate=True, linewidth=0)
if xlabel is not None:
plt.xlabel(xlabel, **axis_font)
if ylabel is not None:
plt.ylabel(ylabel, **axis_font)
def _write_xml(self, filePrefix, plotType, ninoIndexNumber):
caption = u'{} of El Niño {} Climate Index'.format(plotType,
ninoIndexNumber)
write_image_xml(
config=self.config,
filePrefix=filePrefix,
componentName='Ocean',
componentSubdirectory='ocean',
galleryGroup=u'El Niño {} Climate Index'.format(ninoIndexNumber),
groupLink='nino',
thumbnailDescription=plotType,
imageDescription=caption,
imageCaption=caption)
def _plot_size_y_axis(self, x, ys, xmin, xmax):
"""
Get the maximum y value over the given range of x values
Parameters
----------
x : numpy.array
x values
ys : list of numpy.array
a list of curves (y values)
xmin : float
The minimum x value
xmax : float, optional
The maximum x values
"""
# Authors
# -------
# Luke Van Roekel, Xylar Asay-Davis
mask = np.logical_and(x >= xmin, x <= xmax)
# find maximum value of three curves plotted
maxY = -1E20
for y in ys:
maxY = max(y[mask].max(), maxY)
# check the function interpolated to the max/min as well
# Note: flipping the axis so x is in increasing order
maxY = max(np.interp(xmin, x[::-1], y[::-1]), maxY)
maxY = max(np.interp(xmax, x[::-1], y[::-1]), maxY)
return maxY
# }}}