Arctic Atmosphere in a Warming Climate: ERA5 Sea-Ice Profiles#

The Arctic is warming two to four times faster than the global mean — a phenomenon known as Arctic amplification. Understanding how the vertical structure of temperature and humidity responds to declining sea ice is central to the (AC)³ project (Arctic Amplification: Climate Relevant Atmospheric and Surface Processes, and Feedback Mechanisms).

This notebook visualises decadal trends in atmospheric profiles binned by season and sea-ice cover fraction, derived from ERA5 reanalysis data. All data live in xarray Datasets, which makes the trend analysis — including the polyfit call — a single line of code.

Datasetdata/era5_sea_ice_to_libradtran.nc (pre-processed by era5_seasonal_sea_ice_profiles.ipynb):

Dimension

Description

season

DJF · MAM · JJA · SON

valid_time

Decade index (1979 – present)

sea_ice_cover

Fractional sea-ice concentration (0 → 1)

pressure_level

Atmospheric pressure in hPa

The variables t (temperature, K) and q (specific humidity, kg kg⁻¹) are ensemble means within each bin. A linear polyfit along valid_time converts the decadal time axis into a trend (K decade⁻¹ or g kg⁻¹ decade⁻¹).

# pip install cmcrameri
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import cmocean
import cmcrameri.cm as cmc

# ── Global style ────────────────────────────────────────────────────────────
plt.rcParams.update({
    "font.family": "sans-serif",
    "font.size": 13,
    "axes.titlesize": 14,
    "axes.labelsize": 13,
    "xtick.labelsize": 11,
    "ytick.labelsize": 11,
    "figure.dpi": 120,
    "axes.spines.top": False,
    "axes.spines.right": False,
})

ds_out = xr.open_dataset('data/era5_sea_ice_to_libradtran.nc', chunks='auto')
/home/josh/micromamba/envs/mamba_josh/lib/python3.13/site-packages/pyproj/network.py:59: UserWarning: pyproj unable to set PROJ database path.
  _set_context_ca_bundle_path(ca_bundle_path)
ds_out
<xarray.Dataset> Size: 100kB
Dimensions:         (season: 4, valid_time: 8, sea_ice_cover: 10,
                     pressure_level: 13)
Coordinates:
  * pressure_level  (pressure_level) int64 104B 50 100 150 200 ... 850 925 1000
  * season          (season) <U3 48B 'DJF' 'MAM' 'JJA' 'SON'
  * valid_time      (valid_time) datetime64[ns] 64B 1982-07-02T12:00:00 ... 2...
  * sea_ice_cover   (sea_ice_cover) float64 80B 0.05 0.15 0.25 ... 0.85 0.95
Data variables:
    t               (season, valid_time, sea_ice_cover, pressure_level) float64 33kB dask.array<chunksize=(4, 8, 10, 13), meta=np.ndarray>
    q               (season, valid_time, sea_ice_cover, pressure_level) float64 33kB dask.array<chunksize=(4, 8, 10, 13), meta=np.ndarray>
    theta           (season, valid_time, sea_ice_cover, pressure_level) float64 33kB dask.array<chunksize=(4, 8, 10, 13), meta=np.ndarray>
ds_out['theta'] = ds_out['t'] * (1000 / ds_out['pressure_level'])**0.286  # dry potential temperature (K)
ds_trend = ds_out - ds_out.mean(dim='valid_time')                          # anomaly relative to decadal mean

Absolute Temperature Profiles by Decade#

While trends reveal how fast the Arctic is changing, the absolute temperature structure determines which radiative transfer regime applies in pyRadtran simulations. Each panel below shows a single decade for the DJF season, binned by sea-ice fraction.

fg = ds_out.t.isel(season=0).plot.contourf(
    levels=np.arange(210, 281, 5),
    cmap=cmocean.cm.thermal,
    col="valid_time",
    col_wrap=4,
    yscale="log",
    y="pressure_level",
    ylim=(1000, 100),
    add_colorbar=False,
)

for ax in fg.axs.flat:
    ax.set_xlabel("Sea-ice fraction", fontsize=12)
    ax.set_ylabel("Pressure (hPa)", fontsize=12)
    ax.yaxis.set_major_formatter(mticker.ScalarFormatter())
    ax.yaxis.set_minor_formatter(mticker.NullFormatter())
    ax.tick_params(labelsize=11)

season_name = str(ds_out.season.values[0])
fg.fig.suptitle(
    f"ERA5 Temperature Profiles by Decade — Season: {season_name}\nGrouped by Sea-Ice Fraction",
    y=1.03, fontsize=15, fontweight="bold",
)
mappable = fg.axs.flat[0].collections[0]
cbar_ax = fg.fig.add_axes([0.78, 0.95, 0.18, 0.04])   # [left, bottom, width, height]
cb = fg.fig.colorbar(mappable, cax=cbar_ax, orientation='horizontal')
cb.set_label(r"$\Delta\theta$ (K decade$^{-1}$)", fontsize=11)
cb.ax.tick_params(labelsize=10)
#cb.ax.set_xticks([-0.05, -0.025, 0.00, 0.025, 0.05])




fg.fig.set_size_inches(18, 5)
plt.tight_layout()
plt.show()
/tmp/ipykernel_15137/3686798213.py:35: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout()
../_images/2c60258dad7398abbede37d0fdf0b0693df0894d1ac14b0accbdbd95862a4e7d.png

Decadal Snapshots: Temperature and Humidity Anomalies#

The panels below show how the full atmospheric profile evolves decade by decade for a fixed season, grouped by sea-ice fraction. Plotting both the absolute temperature and the anomaly relative to the decadal mean side-by-side highlights the signal-to-noise ratio of the Arctic warming signal.

Because both variables are stored in a single xarray Dataset, switching between absolute values and anomalies is a one-line subtraction — the kind of operation that makes xarray indispensable for climate data analysis.

season_idx = 0  # DJF
season_name = str(ds_out.season.values[season_idx])

# ── Temperature anomaly ──────────────────────────────────────────────────────
max_t = float(np.abs(ds_trend.t.isel(season=season_idx)).quantile(0.98))
levels_t = np.linspace(-max_t, max_t, 31)

fg_t = ds_trend.t.isel(season=season_idx).plot.contourf(
    col="valid_time",
    col_wrap=4,
    yscale="log",
    y="pressure_level",
    ylim=(1000, 100),
    levels=levels_t,
    cmap=cmc.vik,
    add_colorbar=False
)

for ax in fg_t.axs.flat:
    ax.set_xlabel("Sea-ice fraction", fontsize=12)
    ax.set_ylabel("Pressure (hPa)", fontsize=12)
    ax.yaxis.set_major_formatter(mticker.ScalarFormatter())
    ax.yaxis.set_minor_formatter(mticker.NullFormatter())
    ax.tick_params(labelsize=11)

fg_t.fig.suptitle(
    f"ERA5 Temperature Anomaly by Decade — Season: {season_name}\n(relative to decadal mean)",
    y=1.03, fontsize=15, fontweight="bold",
)
mappable = fg_t.axs.flat[0].collections[0]
cbar_ax = fg_t.fig.add_axes([0.78, 0.95, 0.18, 0.04])   # [left, bottom, width, height]
cb = fg_t.fig.colorbar(mappable, cax=cbar_ax, orientation='horizontal')
cb.set_label(r"$\Delta\theta$ (K decade$^{-1}$)", fontsize=11)
cb.ax.tick_params(labelsize=10)
fg_t.fig.set_size_inches(18, 5)
plt.tight_layout()
plt.show()

# ── Specific humidity anomaly ────────────────────────────────────────────────
max_q = float(np.abs(ds_trend.q.isel(season=season_idx)).quantile(0.98)) * 1000  # g/kg

fg_q = (ds_trend.q.isel(season=season_idx) * 1000).plot.contourf(
    col="valid_time",
    col_wrap=4,
    yscale="log",
    y="pressure_level",
    ylim=(1000, 100),
    levels=np.linspace(-max_q, max_q, 31),
    cmap=cmc.vik,
    add_colorbar=False
)

for ax in fg_q.axs.flat:
    ax.set_xlabel("Sea-ice fraction", fontsize=12)
    ax.set_ylabel("Pressure (hPa)", fontsize=12)
    ax.yaxis.set_major_formatter(mticker.ScalarFormatter())
    ax.yaxis.set_minor_formatter(mticker.NullFormatter())
    ax.tick_params(labelsize=11)

fg_q.fig.suptitle(
    f"ERA5 Specific Humidity Anomaly by Decade — Season: {season_name}\n(relative to decadal mean)",
    y=1.03, fontsize=15, fontweight="bold",
)
mappable = fg_q.axs.flat[0].collections[0]
cbar_ax = fg_q.fig.add_axes([0.78, 0.95, 0.18, 0.04])   # [left, bottom, width, height]
cb = fg_q.fig.colorbar(mappable, cax=cbar_ax, orientation='horizontal')
cb.set_label(r"$\Delta q$ (g kg$^{-1}$ decade$^{-1}$)", fontsize=11)
cb.ax.tick_params(labelsize=10)
cb.ax.set_xticks([-0.05, -0.025, 0.00, 0.025, 0.05])
fg_q.fig.set_size_inches(18, 5)
plt.tight_layout()
plt.show()
/tmp/ipykernel_15137/4107186629.py:36: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout()
../_images/84b2c72b5c173ae9cdfb0e27d85804434c5853687e87315bcb4d60ee5ace7950.png
/tmp/ipykernel_15137/4107186629.py:71: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
  plt.tight_layout()
../_images/86410c109be9a58bff3336800f0512a26f9296a61993a716d0ee44d11e48e439.png