"""
Sea Ice Diagnostics.
====================
Diagnostic to produce a series of images which are useful for evaluating
the behaviour of the a sea ice model.
There are three kinds of plots shown here.
1. Sea ice Extent maps plots with a stereoscoic projection.
2. Maps plots of individual models ice fracrtion.
3. Time series plots for the total ice extent.
All three kinds of plots are made for both Summer and Winter in both the
North and Southern hemisphere.
Note that this diagnostic assumes that the preprocessors do the bulk of the
hard work, and that the cube received by this diagnostic (via the settings.yml
and metadata.yml files) has no time component, a small number of depth layers,
and a latitude and longitude coordinates.
This diagnostic takes data from either North or South hemisphere, and
from either December-January-February or June-July-August. This diagnostic
requires the data to be 2D+time, and typically expects the data field to be
the sea ice cover.
An approproate preprocessor would be::
preprocessors:
timeseries_NHW_ice_extent: # North Hemisphere Winter ice_extent
custom_order: true
extract_time:
start_year: 1960
start_month: 12
start_day: 1
end_year: 2005
end_month: 9
end_day: 31
extract_season:
season: DJF
extract_region:
start_longitude: -180.
end_longitude: 180.
start_latitude: 0.
end_latitude: 90.
Note that this recipe may not function on machines with no access to the
internet, as cartopy may try to download the shapefiles. The solution to
this issue is the put the relevant cartopy shapefiles on a disk visible to your
machine, then link that path to ESMValTool via the `auxiliary_data_dir`
variable. The cartopy masking files can be downloaded from::
https://www.naturalearthdata.com/downloads/
Here, cartopy uses the 1:10, physical coastlines and land files::
110m_coastline.dbf 110m_coastline.shp 110m_coastline.shx
110m_land.dbf 110m_land.shp 110m_land.shx
This tool is part of the ocean diagnostic tools package in the ESMValTool.
Author: Lee de Mora (PML)
ledm@pml.ac.uk
"""
import itertools
import logging
import os
import sys
import cartopy
import iris
import iris.coord_categorisation
import iris.quickplot as qplt
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
from esmvaltool.diag_scripts.ocean import diagnostic_tools as diagtools
from esmvaltool.diag_scripts.shared import run_diagnostic
from esmvaltool.diag_scripts.shared._base import ProvenanceLogger
# This part sends debug statements to stdout
logger = logging.getLogger(os.path.basename(__file__))
logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
# Note that this recipe may not function on machines with no access to
# the internet, as cartopy may try to download geographic files.
[docs]
def create_ice_cmap(threshold=0.15):
"""
Create colour map with ocean blue below a threshold and white above.
Parameters
----------
threshold: float
The threshold for the line between blue and white.
Returns
-------
matplotlib.colors.LinearSegmentedColormap:
The resulting colour map.
"""
threshold = threshold / 100.0
ice_cmap_dict = {
"red": (
(0.0, 0.0313, 0.0313),
(threshold, 0.0313, 1.0),
(1.0, 1.0, 1.0),
),
"green": (
(0.0, 0.237, 0.237),
(threshold, 0.237, 1.0),
(1.0, 1.0, 1.0),
),
"blue": (
(0.0, 0.456, 0.456),
(threshold, 0.456, 1.0),
(1.0, 1.0, 1.0),
),
}
return matplotlib.colors.LinearSegmentedColormap("ice_cmap", ice_cmap_dict)
[docs]
def calculate_area_time_series(cube, plot_type, threshold):
"""
Calculate the area of unmasked cube cells.
Requires a cube with two spacial dimensions. (no depth coordinate).
Parameters
----------
cube: iris.cube.Cube
Data Cube
plot_type: str
The type of plot: ice extent or ice area
threshold: float
The threshold for ice fraction (typically 15%)
Returns
-------
numpy array:
An numpy array containing the time points.
numpy.array:
An numpy array containing the total ice extent or total ice area.
"""
data = []
times = diagtools.cube_time_to_float(cube)
for time_itr, time in enumerate(times):
icedata = cube[time_itr].data
area = iris.analysis.cartography.area_weights(cube[time_itr])
if plot_type.lower() == "ice extent":
# Ice extend is the area with more than 15% ice cover.
icedata = np.ma.masked_where(icedata < threshold, icedata)
total_area = np.ma.masked_where(icedata.mask, area.data).sum()
if plot_type.lower() == "ice area":
# Ice area is cover * cell area
total_area = np.sum(icedata * area)
logger.debug(
"Calculating time series area: %s, %s, %s,",
time_itr,
time,
total_area,
)
data.append(total_area)
######
# Create a small dummy output array
data = np.array(data)
return times, data
[docs]
def make_ts_plots(
cfg,
metadata,
filename,
):
"""
Make a ice extent and ice area time series plot for an individual model.
Parameters
----------
cfg: dict
the opened global config dictionairy, passed by ESMValTool.
metadata: dict
The metadata dictionairy for a specific model.
filename: str
The preprocessed model file.
"""
# Load cube and set up units
cube = iris.load_cube(filename)
iris.coord_categorisation.add_year(cube, "time")
cube = diagtools.bgc_units(cube, metadata["short_name"])
cube = agregate_by_season(cube)
# Is this data is a multi-model dataset?
multi_model = metadata["dataset"].find("MultiModel") > -1
# Make a dict of cubes for each layer.
cubes = diagtools.make_cube_layer_dict(cube)
# Load image format extention
image_extention = diagtools.get_image_format(cfg)
# # Load threshold, pole, season.
threshold = float(cfg["threshold"])
pole = get_pole(cube)
season = get_season(cube)
# Making plots for each layer
for plot_type in ["Ice Extent", "Ice Area"]:
for layer_index, (layer, cube_layer) in enumerate(cubes.items()):
layer = str(layer)
times, data = calculate_area_time_series(
cube_layer, plot_type, threshold
)
plt.plot(times, data)
# Add title to plot
title = " ".join(
[metadata["dataset"], pole, "hemisphere", season, plot_type]
)
if layer:
title = " ".join(
[
title,
"(",
layer,
str(cube_layer.coords("depth")[0].units),
")",
]
)
plt.title(title)
# y axis label:
plt.ylabel(" ".join([plot_type, "m^2"]))
# Determine image filename:
suffix = (
"_".join(
[
"ts",
metadata["preprocessor"],
season,
pole,
plot_type,
str(layer_index),
]
)
+ image_extention
)
suffix = suffix.replace(" ", "")
if multi_model:
path = diagtools.folder(cfg["plot_dir"]) + os.path.basename(
filename
)
path = path.replace(".nc", suffix)
else:
path = diagtools.get_image_path(
cfg,
metadata,
suffix=suffix,
)
# Saving files:
logger.info("Saving plots to %s", path)
plt.savefig(path)
plt.close()
provenance_record = diagtools.prepare_provenance_record(
cfg,
caption=f"Time serie of {title}",
statistics=["mean"],
domain=["polar"],
plot_type=["times"],
ancestors=[filename],
)
with ProvenanceLogger(cfg) as provenance_logger:
provenance_logger.log(path, provenance_record)
[docs]
def make_polar_map(
cube,
pole="North",
cmap="Blues_r",
):
"""
Make a polar stereoscopic map plot.
The cube is the opened cube (two dimensional),
pole is the polar region (North/South)
cmap is the colourmap,
Parameters
----------
cube: iris.cube.Cube
Data Cube
pole: str
The hemisphere
cmap: str
The string describing the matplotlib colourmap.
Returns
----------
matplotlib.pyplot.figure:
The matplotlib figure where the map was drawn.
matplotlib.pyplot.axes:
The matplotlib axes where the map was drawn.
"""
fig = plt.figure()
fig.set_size_inches(7, 7)
# ####
# Set limits, based on https://nedbatchelder.com/blog/200806/pylint.html
if pole not in ["North", "South"]:
logger.fatal("make_polar_map: hemisphere not provided.")
if pole == "North": # North Hemisphere
ax1 = plt.subplot(111, projection=cartopy.crs.NorthPolarStereo())
ax1.set_extent([-180, 180, 50, 90], cartopy.crs.PlateCarree())
if pole == "South": # South Hemisphere
ax1 = plt.subplot(111, projection=cartopy.crs.SouthPolarStereo())
ax1.set_extent([-180, 180, -90, -50], cartopy.crs.PlateCarree())
linrange = np.linspace(0, 100, 21)
qplt.contourf(cube, linrange, cmap=cmap, linewidth=0, rasterized=True)
plt.tight_layout()
try:
ax1.add_feature(
cartopy.feature.LAND,
zorder=10,
facecolor=[0.8, 0.8, 0.8],
)
except ConnectionRefusedError:
logger.error(
"Cartopy was unable add coastlines due to a connection error."
)
ax1.gridlines(
linewidth=0.5, color="black", zorder=20, alpha=0.5, linestyle="--"
)
try:
plt.gca().coastlines()
except AttributeError:
logger.warning("make_polar_map: Not able to add coastlines")
return fig
[docs]
def get_pole(cube):
"""
Figure out the hemisphere and returns it as a string (North or South).
Parameters
----------
cube: iris.cube.Cube
Data Cube
Returns
----------
str:
The hemisphere (North or South)
"""
margin = 5.0
if np.max(cube.coord("latitude").points) < 0.0 + margin:
return "South"
if np.min(cube.coord("latitude").points) > 0.0 - margin:
return "North"
logger.fatal("get_pole: Not able to determine hemisphere.")
return False
[docs]
def get_time_string(cube):
"""
Return a climatological season string in the format: "year season".
Parameters
----------
cube: iris.cube.Cube
Data Cube
Returns
----------
str:
The climatological season as a string
"""
season = cube.coord("clim_season").points
year = cube.coord("year").points
return str(int(year[0])) + " " + season[0].upper()
[docs]
def get_year(cube):
"""
Return the cube year as a string.
Parameters
----------
cube: iris.cube.Cube
Data Cube
Returns
----------
str:
The year as a string
"""
year = cube.coord("year").points
return str(int(year))
[docs]
def get_season(cube):
"""
Return a climatological season time string.
Parameters
----------
cube: iris.cube.Cube
Data Cube
Returns
----------
str:
The climatological season as a string
"""
season = cube.coord("clim_season").points
return season[0].upper()
[docs]
def make_map_plots(
cfg,
metadata,
filename,
):
"""
Make a simple map plot for an individual model.
Parameters
----------
cfg: dict
the opened global config dictionairy, passed by ESMValTool.
metadata: dict
The metadata dictionairy for a specific model.
filename: str
The preprocessed model file.
"""
# Load cube and set up units
cube = iris.load_cube(filename)
iris.coord_categorisation.add_year(cube, "time")
cube = diagtools.bgc_units(cube, metadata["short_name"])
cube = agregate_by_season(cube)
# Is this data is a multi-model dataset?
multi_model = metadata["dataset"].find("MultiModel") > -1
# Make a dict of cubes for each layer.
cubes = diagtools.make_cube_layer_dict(cube)
# Load image format extention and threshold.
image_extention = diagtools.get_image_format(cfg)
threshold = float(cfg["threshold"])
# Making plots for each layer
plot_types = ["Fractional cover", "Ice Extent"]
plot_times = [0, -1]
for plot_type, plot_time in itertools.product(plot_types, plot_times):
for layer_index, (layer, cube_layer) in enumerate(cubes.items()):
layer = str(layer)
if plot_type == "Fractional cover":
cmap = "Blues_r"
if plot_type == "Ice Extent":
cmap = create_ice_cmap(threshold)
cube = cube_layer[plot_time]
# use cube to determine which hemisphere, season and year.
pole = get_pole(cube)
time_str = get_time_string(cube)
# Make the polar map.
make_polar_map(cube, pole=pole, cmap=cmap)
# Add title to plot
title = " ".join([metadata["dataset"], plot_type, time_str])
if layer:
title = " ".join(
[
title,
"(",
layer,
str(cube_layer.coords("depth")[0].units),
")",
]
)
plt.title(title)
# Determine image filename:
suffix = "_".join(
["ortho_map", plot_type, time_str, str(layer_index)]
)
suffix = suffix.replace(" ", "") + image_extention
if multi_model:
path = diagtools.folder(cfg["plot_dir"])
path = path + os.path.basename(filename)
path = path.replace(".nc", suffix)
else:
path = diagtools.get_image_path(
cfg,
metadata,
suffix=suffix,
)
# Saving files:
logger.info("Saving plots to %s", path)
plt.savefig(path)
plt.close()
provenance_record = diagtools.prepare_provenance_record(
cfg,
caption=f"Map of {title}",
statistics=["mean"],
domain=["polar"],
plot_type=["map"],
ancestors=[filename],
)
with ProvenanceLogger(cfg) as provenance_logger:
provenance_logger.log(path, provenance_record)
[docs]
def agregate_by_season(cube):
"""
Aggregate the cube into seasonal means.
Note that it is not currently possible to do this in the preprocessor,
as the seasonal mean changes the cube units.
Parameters
----------
cube: iris.cube.Cube
Data Cube
Returns
----------
iris.cube.Cube:
Data Cube with the seasonal means
"""
if not cube.coords("clim_season"):
iris.coord_categorisation.add_season(cube, "time", name="clim_season")
if not cube.coords("season_year"):
iris.coord_categorisation.add_season_year(
cube, "time", name="season_year"
)
return cube.aggregated_by(
["clim_season", "season_year"], iris.analysis.MEAN
)
[docs]
def make_map_extent_plots(
cfg,
metadata,
filename,
):
"""
Make an extent map plot showing several times for an individual model.
Parameters
----------
cfg: dict
the opened global config dictionairy, passed by ESMValTool.
metadata: dict
The metadata dictionairy for a specific model.
filename: str
The preprocessed model file.
"""
# Load cube and set up units
cube = iris.load_cube(filename)
iris.coord_categorisation.add_year(cube, "time")
cube = diagtools.bgc_units(cube, metadata["short_name"])
cube = agregate_by_season(cube)
# Is this data is a multi-model dataset?
multi_model = metadata["dataset"].find("MultiModel") > -1
# Make a dict of cubes for each layer.
cubes = diagtools.make_cube_layer_dict(cube)
# Load image format extention
image_extention = diagtools.get_image_format(cfg)
# Load threshold, pole and season
threshold = float(cfg["threshold"])
pole = get_pole(cube)
season = get_season(cube)
# Start making figure
for layer_index, (layer, cube_layer) in enumerate(cubes.items()):
fig = plt.figure()
fig.set_size_inches(7, 7)
if pole == "North": # North Hemisphere
projection = cartopy.crs.NorthPolarStereo()
ax1 = plt.subplot(111, projection=projection)
ax1.set_extent([-180, 180, 50, 90], cartopy.crs.PlateCarree())
if pole == "South": # South Hemisphere
projection = cartopy.crs.SouthPolarStereo()
ax1 = plt.subplot(111, projection=projection)
ax1.set_extent([-180, 180, -90, -50], cartopy.crs.PlateCarree())
try:
ax1.add_feature(
cartopy.feature.LAND, zorder=10, facecolor=[0.8, 0.8, 0.8]
)
except ConnectionRefusedError:
logger.error(
"Cartopy was unable add coastlines due to a connection error."
)
ax1.gridlines(
linewidth=0.5, color="black", zorder=20, alpha=0.5, linestyle="--"
)
try:
plt.gca().coastlines()
except AttributeError:
logger.warning("make_polar_map: Not able to add coastlines")
times = np.array(cube.coord("time").points.astype(float))
plot_desc = {}
for time_itr, time in enumerate(times):
cube = cube_layer[time_itr]
line_width = 1
color = plt.cm.jet(float(time_itr) / float(len(times)))
label = get_year(cube)
plot_desc[time] = {
"label": label,
"c": [
color,
],
"lw": [
line_width,
],
"ls": [
"-",
],
}
layer = str(layer)
qplt.contour(
cube,
[
threshold,
],
colors=plot_desc[time]["c"],
linewidths=plot_desc[time]["lw"],
linestyles=plot_desc[time]["ls"],
rasterized=True,
)
# Add legend
legend_size = len(plot_desc) + 1
ncols = int(legend_size / 25) + 1
ax1.set_position(
[
ax1.get_position().x0,
ax1.get_position().y0,
ax1.get_position().width * (1.0 - 0.1 * ncols),
ax1.get_position().height,
]
)
fig.set_size_inches(7 + ncols * 1.2, 7)
# Construct dummy plots.
for i in sorted(plot_desc):
plt.plot(
[],
[],
c=plot_desc[i]["c"][0],
lw=plot_desc[i]["lw"][0],
ls=plot_desc[i]["ls"][0],
label=plot_desc[i]["label"],
)
legd = ax1.legend(
loc="center left",
ncol=ncols,
prop={"size": 10},
bbox_to_anchor=(1.0, 0.5),
)
legd.draw_frame(False)
legd.get_frame().set_alpha(0.0)
# Add title to plot
title = " ".join(
[
metadata["dataset"],
]
)
if layer:
title = " ".join(
[
title,
"(",
layer,
str(cube_layer.coords("depth")[0].units),
")",
]
)
plt.title(title)
# Determine image filename:
suffix = "_".join(["ortho_map", pole, season, str(layer_index)])
suffix = suffix.replace(" ", "") + image_extention
if multi_model:
path = diagtools.folder(cfg["plot_dir"])
path = path + os.path.basename(filename)
path = path.replace(".nc", suffix)
else:
path = diagtools.get_image_path(
cfg,
metadata,
suffix=suffix,
)
# Saving files:
logger.info("Saving plots to %s", path)
plt.savefig(path)
plt.close()
provenance_record = diagtools.prepare_provenance_record(
cfg,
caption=f"Temporal extent of {title}",
statistics=["mean"],
domain=["polar"],
plot_type=["map"],
ancestors=[filename],
)
with ProvenanceLogger(cfg) as provenance_logger:
provenance_logger.log(path, provenance_record)
[docs]
def main(cfg):
"""
Load the config file and metadata, then pass them the plot making tools.
Parameters
----------
cfg: dict
the opened global config dictionairy, passed by ESMValTool.
"""
cartopy.config["data_dir"] = cfg["auxiliary_data_dir"]
for index, metadata_filename in enumerate(cfg["input_files"]):
logger.info(
"metadata filename:\t%s",
metadata_filename,
)
metadatas = diagtools.get_input_files(cfg, index=index)
for filename in sorted(metadatas):
logger.info("-----------------")
logger.info(
"model filenames:\t%s",
filename,
)
######
# extent maps plots of individual models
make_map_extent_plots(cfg, metadatas[filename], filename)
######
# maps plots of individual models
make_map_plots(cfg, metadatas[filename], filename)
######
# time series plots o
make_ts_plots(cfg, metadatas[filename], filename)
logger.info("Success")
if __name__ == "__main__":
with run_diagnostic() as config:
main(config)