logo

Please note that this repository is used for development and review, so quality assessments should be considered work in progress until they are merged into the main branch

2.1. Reference Upper-Air Network for trend analysis#

Production date: 11-11-2024

Produced by: V. Ciardini (ENEA), P. Grigioni (ENEA), G. Pace (ENEA), C. Scarchilli (ENEA)

🌍 Use case: A study on changing temperature over the Arctic region#

❓ Quality assessment question#

  • Is the tropospheric Arctic warming amplification consistently detectable (across sites) in the GRUAN measurements?

The evidences, both direct and indirect, indicate a substantial tropospheric warming of the Arctic over recent decades. Furthermore, models project that the region will warm more rapidly than the global average over the remainder of this century, with likely considerable impacts on the environment, ecosystems and human activities. Amplifying feedback mechanisms are based on atmosphere-cryosphere-ocean interactions, with the diminishing of the Arctic Sea ice cover playing a leading role. Understanding the climate change, and the underlying causes, requires an understanding not just of changes at the surface of the Earth, but throughout the atmospheric column.

📢 Quality assessment statement#

These are the key outcomes of this assessment

  • GRUAN provides reference-quality measurements with documented uncertainties, making it well-suited to the detailed analysis of Arctic atmospheric column characteristics. Analysis confirms that tropospheric Arctic warming is detectable in GRUAN measurements, though its magnitude and statistical significance vary across sites and seasons, reflecting regional heterogeneity in Arctic amplification. Temporal coverage can also constrain trend analysis, as it depends on when each station joined GRUAN. Nevertheless, the observational frequency is generally high, ranging from two to three launches per week to two to four launches per day, ensuring robust sampling of the atmospheric column.

  • Positive temperature trends dominate the lower troposphere at all three Arctic stations, with Sodankylä exhibiting the strongest and most significant warming signal. Ny-Ålesund exhibits more uniform but weaker warming, while Barrow shows mixed signals with seasonal cooling episodes.

  • Stratospheric trends are generally weak and less consistent, with only isolated significant signals (e.g. cooling at 13–15 km over Sodankylä), indicating that robust detection of stratospheric changes requires a longer time series.

  • Specific humidity trends are more variable than temperature trends. Sodankylä shows clear moistening in the free troposphere and a statistically significant increase in integrated water vapour (IWV). Ny-Ålesund and Barrow, however, exhibit weaker and mostly non-significant trends.

📋 Methodology#

To answer the proposed question, we intend to focus on observations at Ny-Ålesund, Sodankyla and Barrow, the three Arctic GRUAN stations, following part of the methodology of [1], where a homogenised 22-year Ny-Ålesund radiosonde dataset was analysed to infer changes in vertical temperature and humidity profiles over two decades (1993 to 2014). It should be noted that Ny-Ålesund has been the first radiosonde station certified by GRUAN. The authors found that in NYA, the integrated water vapour (IWV) calculated from radiosonde humidity measurements over two decades, indicates an increase in atmospheric moisture over the years (much faster in winter) and a corresponding warming of the atmospheric column in January and February.

The analysis and results are organised in the following steps, which are detailed in the sections below:

1. Set up and data retrieval for the three stations (NYA, SOD and BAR).

2. Monthly mean temperature and seasonal profiles

  • Radiosounding data are interpolated on a regular altitude grid with 50 m vertical resolution; the seasonal profile and standard deviation of temperature and specific humidity are calculated

3.Temperature anomalies and trends

4. Specific humidity and IWV anomalies and trends

📈 Analysis and results#

1. Set up and data retrieval for the three stations (NYA, SOD and BAR).#

The User will be working with data in netcdf format. To konw more about the GRUAN dataset see CDS entries documentation section.

Code:

  • Import CDSAPI credential and packages;

  • define parameters (time period, stations, downloaded file directory);

  • define request and functions to cache;

  • read data and compute functions.

Import packages#

Hide code cell source

import os
import cdsapi
import matplotlib
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import numpy as np
import pandas as pd
import scipy.stats
import xarray as xr

from scipy.stats import linregress
from c3s_eqc_automatic_quality_control import download

plt.style.use("seaborn-v0_8-notebook")

Define Parameters#

Time range of data series can be set by the User; different stations can be selected by means of Station acronyms (list of GRUAN station acronyms, locations, geographical coordinates and WMO n. are reported in the Product User Guide and Specification for GRUAN Temperature, Relative Humidity and Wind profiles available at [2]).

Hide code cell source

# Time period
start = "2006-05"
stop = "2020-03"

# Stations
stations = ["BAR", "SOD", "NYA"]  # Use None to analyse all stations
assert isinstance(stations, list | None)

# Directory for csv files
csv_dir = "./csv_files"

# CDS credentials
os.environ["CDSAPI_RC"] = os.path.expanduser("~/ciardini_virginia/.cdsapirc")

Define request#

Hide code cell source

collection_id = "insitu-observations-gruan-reference-network"
request = {
    "version": "1_0_0",
    "variable": [
        "air_temperature",
        "relative_humidity",
        "air_pressure",
        "altitude",
        "water_vapour_volume_mixing_ratio",
    ],
    "data_format": "netcdf",
}

client = cdsapi.Client(sleep_max=10)
requests = []
for date in pd.date_range(start, stop, freq="1MS"):
    time_request = {"year": date.strftime("%Y"), "month": date.strftime("%m")}
    time_request["day"] = client.client.apply_constraints(
        collection_id, request | time_request
    )["day"]
    if time_request["day"]:
        requests.append(request | time_request)
2025-12-30 15:11:34,365 INFO [2025-12-03T00:00:00Z] To improve our C3S service, we need to hear from you! Please complete this very short [survey](https://confluence.ecmwf.int/x/E7uBEQ/). Thank you.

Functions to cache#

Hide code cell source

def _reorganize_dataset(ds):
    # Rename
    (varname,) = set(ds["observed_variable"].values)
    ds = ds.rename(observation_value=str(varname)).drop_vars("observed_variable")
    ds = ds.rename(
        {
            var: "_".join([varname, var.replace("_value", "")])
            for var in ds.data_vars
            if var.startswith("uncertainty")
        }
    )
    # Update attrs
    for var, da in ds.data_vars.items():
        match var:
            case "pressure":
                da.attrs["long_name"] = "Pressure"
            case "air_temperature":
                da.attrs["long_name"] = "Temperature"
            case "altitude":
                da.attrs["long_name"] = "Altitude"
            case "relative_humidity":
                da.attrs["long_name"] = "Relative"
            case "water_vapour_mixing_ratio":
                da.attrs["long_name"] = "Mixing"
        for string in ("units", "type"):
            if string in var:
                ds = ds.drop_vars(var)
                (value,) = set(da.values)
                attrs_var = varname if var == string else var.replace("_" + string, "")
                ds[attrs_var].attrs[string] = value
    return ds


def reorganize_dataset(ds):
    for var, da in ds.data_vars.items():
        if np.issubdtype(da.dtype, np.bytes_):
            ds[var].values = np.char.decode(da.values, "utf-8")

    if not ds.sizes["index"]:
        return ds

    datasets = []
    for var, ds in ds.groupby("observed_variable"):
        datasets.append(_reorganize_dataset(ds))
    with xr.set_options(use_new_combine_kwarg_defaults=True):
        return xr.merge(datasets)


def compute_specific_humidity_from_water_vapour_mixing_ratio(
    water_vapour_mixing_ratio,
    molar_mass_water=18.01528,
    molar_mass_dry_air=28.9647,
):
    specific_humidity = (
        (molar_mass_water * water_vapour_mixing_ratio)
        / (molar_mass_dry_air + molar_mass_water * water_vapour_mixing_ratio)
        * 1000
    )
    specific_humidity.attrs = {"long_name": "Specific Humidity", "units": "g/kg"}
    return specific_humidity


def compute_integrated_water_vapour(specific_humidity):
    specific_humidity = specific_humidity / 1.0e3  # g/kg → kg/kg
    delta_altitude = specific_humidity["altitude"].diff("altitude").fillna(0)  # m

    integrated_water_vapour = (specific_humidity * delta_altitude).sum("altitude")
    integrated_water_vapour.attrs = {
        "long_name": "Integrated Water Vapour",
        "units": "kg/m²",
    }
    return integrated_water_vapour


def compute_insitu_profiles(ds):
    ds = reorganize_dataset(ds)

    # Add variables
    ds["specific_humidity"] = compute_specific_humidity_from_water_vapour_mixing_ratio(
        ds["water_vapour_mixing_ratio"]
    )
    ds["time"] = ("index", pd.to_datetime(ds["report_timestamp"]).values)

    # Compute profiles
    subset = ["air_temperature", "relative_humidity", "specific_humidity", "altitude"]
    profiles = []
    for station, ds_station in ds.groupby("primary_station_id"):
        for time, profile in ds_station.groupby("time"):
            profile = profile.swap_dims(index="altitude")[subset]
            profile = profile.sortby("altitude")
            profile = profile.dropna("altitude", how="any", subset=subset)
            profile = profile.drop_duplicates("altitude")
            if (profile["altitude"].diff("altitude") > 2_000).any():
                continue
            profile = profile.interp(altitude=range(50, 30_001, 50))
            profile = profile.expand_dims(time=[time])
            profile = profile.assign_coords(station=("time", [station]))
            profiles.append(profile)
    ds = xr.concat(profiles, "time")

    # Add integrated water vapour
    ds["integrated_water_vapour"] = compute_integrated_water_vapour(
        ds["specific_humidity"]
    )
    return ds

Download and transform#

Hide code cell source

ds = download.download_and_transform(
    collection_id,
    requests,
    chunks={"year": 1, "month": 1},
    transform_func=compute_insitu_profiles,
    cached_open_mfdataset_kwargs={"concat_dim": "time", "combine": "nested"},
)
if stations is not None:
    ds = ds.where(ds["station"].isin(stations).compute(), drop=True)
ds = ds.compute()
100%|██████████| 167/167 [00:10<00:00, 16.05it/s]

Hide code cell source

STATIONS = [
    {
        "name": "Barrow (Utqiaġvik)",
        "abbr": "BAR",
        "lat": 71.29,
        "lon": -156.79,
        "color": "red",
        "marker": "*",
    },
    {
        "name": "Ny-Ålesund",
        "abbr": "NYA",
        "lat": 78.923,
        "lon": 11.923,
        "color": "red",
        "marker": "*",
    },
    {
        "name": "Sodankylä",
        "abbr": "SOD",
        "lat": 67.37,
        "lon": 26.63,
        "color": "red",
        "marker": "*",
    },
]

def plot_arctic_three_stations(
    extent=(-180, 180, 55, 90),
    land_color="#d9d9d9",
    ocean_color="#e6f2ff",
    savepath=None,
    dpi=300
):
    """Crea una mappa polare artica con BAR, NYA e SOD."""
    fig = plt.figure(figsize=(8, 8))
    proj = ccrs.NorthPolarStereo()
    ax = plt.subplot(1, 1, 1, projection=proj)

    # Estensione geografica (Nord del ~55°N)
    ax.set_extent(extent, crs=ccrs.PlateCarree())

    # Layer geografici
    ax.add_feature(cfeature.LAND.with_scale("110m"), facecolor=land_color)
    ax.add_feature(cfeature.OCEAN.with_scale("110m"), facecolor=ocean_color)
    ax.add_feature(cfeature.COASTLINE.with_scale("110m"), linewidth=0.6)
    ax.add_feature(cfeature.BORDERS.with_scale("110m"), linewidth=0.3)

    # Graticola (solo linee, senza etichette)
    gl = ax.gridlines(crs=ccrs.PlateCarree(), draw_labels=False,
                      linewidth=0.5, color="gray", alpha=0.5, linestyle="--")

    # Cerchi di latitudine (60°, 70°, 80°)
    for lat_circle in (60, 70, 80):
        theta = np.linspace(0, 360, 361)
        ax.plot(theta, [lat_circle]*len(theta),
                transform=ccrs.Geodetic(), color="lightgray", linewidth=0.4)

    # PIAZZAMENTO STAZIONI
    for st in STATIONS:
        ax.plot(st["lon"], st["lat"], st["marker"],
                color=st["color"], markersize=12,
                transform=ccrs.PlateCarree(), label=st["abbr"])
        # Etichette leggermente spostate per evitare sovrapposizioni
        ax.text(st["lon"] + 3, st["lat"] + 1, st["name"],
                transform=ccrs.PlateCarree(),
                fontsize=10, ha="left", va="bottom", color="black",
                bbox=dict(boxstyle="round,pad=0.2", fc="white", ec="none", alpha=0.7))

    ax.set_title("Arctic map: Ny-Ålesund (NYA), Sodankylä (SOD), Barrow/Utqiaġvik (BAR)", fontsize=12)

    if savepath:
        plt.savefig(savepath, dpi=dpi, bbox_inches="tight")
    plt.show()

Hide code cell source

plot_arctic_three_stations()
../_images/7b26fd242369e414f7385334c6470c3a4ebbae6c2776a8f0c174c9ba25ac8d10.png

Figure 1. The figure shows a map of the three Arctic GRUAN sites, which represent distinct regional environments within the Arctic.

Although Arctic amplification affects the entire Arctic region, its magnitude varies among different sectors and is strongest in the Eurasian sector of the Arctic Ocean. In general, the greatest amplification occurs in regions experiencing the strongest reductions in sea-ice extent, where albedo-feedback processes are particularly effective. However, regional variations in atmospheric circulation [3], water vapour, cloudiness, and geographical setting [4] also contribute to spatial heterogeneity in Arctic amplification. The three Arctic GRUAN stations—Barrow (BAR), Sodankylä (SOD), and Ny-Ålesund (NYA)—are located in different environments and therefore provide complementary perspectives on the Arctic atmosphere, particularly in the lower troposphere where interactions with the surface are more intense. NYA is directly influenced by the Arctic Ocean and by the seasonal presence or absence of sea ice. BAR is also affected by conditions over the nearby Beaufort Sea [5], although its coastal location means that it is also influenced by the Alaskan interior. In contrast, SOD is located inland on the Scandinavian Peninsula, more than 250 km from the coast, and is therefore only indirectly affected by the Arctic Ocean.

2. Monthly mean temperature and seasonal profiles#

Hide code cell source

fig, axs = plt.subplots(1, len(set(ds["station"].values)), sharey=True, figsize=(18, 6))
dataarrays = {
    station: da.resample(time="1MS").mean()
    for station, da in ds["air_temperature"].groupby("station")
}
vmin = min(da.min().values for da in dataarrays.values())
vmax = max(da.max().values for da in dataarrays.values())
for ax, (station, da_station) in zip(axs, dataarrays.items()):
    da_station = da_station.dropna("time", how="all")
    pcm = da_station.plot(
        x="time", cmap="coolwarm", vmin=vmin, vmax=vmax, ax=ax, add_colorbar=False
    )
    ax.set_title(f"Station: {station}")
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
    ax.tick_params(axis="x", rotation=45)
fig.colorbar(pcm, ax=axs, orientation="vertical", label="Temperature [K]")
#_= fig.suptitle("Monthly Mean Temperature")
plt.show()
../_images/ebec0e5590e42985465db2d7c0f6cc063a6b66ae20e2fdb34ab4e3265720b578.png

Figure 2. Monthly mean temperature profiles for the three GRUAN stations (BAR, NYA, and SOD), shown in separate panels. Due to limited data availability for SOD prior to 2011, the time series used in the analysis starts from 2011.

The monthly mean temperature profiles exhibit distinct annual cycles at the three Arctic stations. SOD shows the most pronounced annual cycle in tropospheric temperature, consistent with its more continental like climate, whereas NYA displays the weakest annual amplitude due to its oceanic background. A further difference in the vertical temperature structure among the sites appears in the stratosphere, where BAR shows a much weaker annual cycle than NYA and SOD. These differences are highlighted by the seasonal profiles (Figure 3), which reveal contrasting near-surface thermal structures.

Hide code cell source

season_colors = {
    "DJF": "tab:blue",
    "MAM": "tab:green",
    "JJA": "tab:orange",
    "SON": "tab:red",
}

# Setup figure
fig, axs = plt.subplots(1, len(set(ds["station"].values)), sharey=True, figsize=(18, 6))
for ax, (station, ds_station) in zip(axs, ds.groupby("station")):
    # Compute seasonal mean
    da = ds_station["air_temperature"] - 273.15  # °C
    grouped = da.groupby("time.season")
    ds_seasonal = xr.merge([grouped.mean().rename("mean"), grouped.std().rename("std")])
    for season, color in season_colors.items():
        # Plot
        ds_season = ds_seasonal.sel(season=season)
        ax.plot(
            ds_season["mean"], ds_season["altitude"], label=f"{season}", color=color
        )
        ax.fill_betweenx(
            ds_season["altitude"],
            ds_season["mean"] - ds_season["std"],
            ds_season["mean"] + ds_season["std"],
            color=color,
            alpha=0.3,
        )
    # Ax settings
    ax.set_title(f"{station}")
    ax.set_xlabel("Temperature [°C]")
   # ax.set_ylabel("Altitude [m]")
    ax.grid()
    ax.legend(title="Season")
    ax.set_xlim(-80, 20)
# Figure settings
axs[0].set_ylabel("Altitude [m]")
#_ = fig.suptitle("Seasonal profiles ±1σ")
plt.show()
../_images/083862fcad43814b0ce8f927131e1befb82fa98f0b184d664eb604a3ed584a82.png

Figure 3. Seasonal vertical profiles of air temperature (°C) for each station. Lines indicate the seasonal mean (DJF = blue, MAM = green, JJA = orange, SON = red); shaded bands denote ±1 standard deviation computed within each season.

BAR exhibits persistent surface-based inversions throughout the year, except in autumn when both inversion depth and strength decrease markedly. This seasonal weakening is consistent with previous studies attributing reduced inversion intensity to ocean heat release and delayed sea ice formation (e.g.[5], [6], [7]). SOD shows weaker winter inversions than BAR and maintains higher near-surface temperatures than both BAR and NYA across all seasons, consistent with its inland location and reduced maritime cooling (e.g. [8]). At NYA, the evidence of persistent near-surface inversions is generally absent in seasonal profiles; however, previous studies report frequent shallow inversions (≈ 80 % of profiles) under a variety of meteorological conditions ([1], [9]). Above about 8 km, near the tropopause and in the lower stratosphere, the thermal structure differs among the three stations, likely reflecting stratospheric circulation and polar-vortex dynamics [1]. BAR tends to exhibit slightly warmer tropopause temperatures and a reduced amplitude of the stratospheric annual cycle, whereas NYA and SOD show more similar stratospheric conditions, consistent with a stronger and more persistent influence of the winter polar vortex in those regions.

ℹ️ If you want to know more#

Key resources#

CDS entries, In situ temperature, relative humidity and wind profiles from 2006 to March 2020 from the GRUAN reference network

external pages:

GRUAN website, GCOS Reference Upper-Air Network

Code libraries used:

References#

[1] Maturilli, M., Kayser, M. Arctic warming, moisture increase and circulation changes observed in the Ny-Ålesund homogenized radiosonde record. Theor Appl Climatol 130, 1–17 (2017). https://doi.org/10.1007/s00704-016-1864-0

[2] Product User Guide and Specification for GRUAN Temperature, Relative Humidity and Wind profiles.

[3] Yu, Q., Wu, B. & Zhang, W. The atmospheric connection between the Arctic and Eurasia is underestimated in simulations with prescribed sea ice. Commun Earth Environ 5, 435 (2024). https://doi.org/10.1038/s43247-024-01605-2

[4] Yang, M., Qiu, Y., Huang, L., Cheng, M., Chen, J., Cheng, B., & Jiang, Z. (2023). Changes in Sea Surface Temperature and Sea Ice Concentration in the Arctic Ocean over the Past Two Decades. Remote Sensing, 15(4), 1095. https://doi.org/10.3390/rs15041095

[5] Ballinger, T. J., U. S. Bhatt, P.A. Bieniek, B. Brettschneider, R. T. Lader, J.S. Littell, R.L. Thoman, C.F. Waigl, J. E. Walsh, M. A. Webster (2023) Alaska Terrestrial and Marine Climate Trends, 1957–2021. J. Climate, 36, 4375–4391, https://doi.org/10.1175/JCLI-D-22-0434.1

[6] Overland, J.E. and Wang, M. (2010), Large-scale atmospheric circulation changes are associated with the recent loss of Arctic sea ice. Tellus A, 62: 1-9. https://doi.org/10.1111/j.1600-0870.2009.00421.x

[7] Serreze, M. C., Barrett, A. P., Stroeve, J. C., Kindig, D. N., and Holland, M. M.: The emergence of surface-based Arctic amplification, The Cryosphere, 3, 11–19, https://doi.org/10.5194/tc-3-11-2009, 2009.

[8] Remes, T., Køltzow, M., & Kähnert, M. (2025). Spatial variability of near‐surface air temperature in the Copernicus Arctic regional reanalysis. Quarterly Journal of the Royal Meteorological Society, 151.

[9] Wang, D., Guo, J., Xu, H., Li, J., Lv, Y., Solanki, R., … & Rinke, A. (2021). Vertical structures of temperature inversions and clouds derived from high-resolution radiosonde measurements at Ny-Ålesund, Svalbard. Atmospheric Research, 254, 105530.

[10] Nygård, T., Tjernström, M., & Naakka, T. (2021). Winter thermodynamic vertical structure in the Arctic atmosphere linked to large scale circulation.