Chapter Relevance
Lab Relevance: ★★★ (Aggregating time series and calculating anomalies are mandatory procedures in almost all climate-related labs.)
Project Relevance: ★★★ (Crucial for any project analyzing long-term environmental change, seasonal variations, or global warming trends.)
Foundation: ★★★ (Teaches the foundational distinction between changing temporal frequency and categorical grouping.)
Time to Read: 15 minutes
In a nutshell: Master how to reorganize time series data to extract climatological baselines, calculate anomalies, and smooth out noisy weather data to reveal true long-term trends.
Skip this if: You are already intimately familiar with xarray’s .resample(), .groupby(), and .rolling() methods.
Demonstrating how to effectively collapse, reorganize, and smooth the time dimension in Python is the core pedagogical goal of this chapter. You will learn the critical differences between resampling and grouping, and master the split, apply, combine workflow to produce interpretable environmental statistics.
Applied Scenario We will analyze the NOAA Extended Reconstructed Sea Surface Temperature (ERSST v5) dataset, a widely used and trusted gridded compilation of historical data going back to 1854. Our goal is to strip away the overwhelming seasonal cycle to reveal long term climate warming trends.
import numpy as np
import xarray as xr
import matplotlib.pyplot as plt
import cmcrameri.cm as cmc
# Load the historical sea surface temperature dataset
ds = xr.tutorial.open_dataset("ersstv5")1. Basic Temporal Reductions¶
We begin by treating time as a fully reducible dimension, exactly like latitude or longitude. Reductions are functions that reduce the dimensionality of our dataset. Rather than dealing with complex loops across hundreds of temporal slices, you can perform reductions across the entire timeline simultaneously by using standard reduction methods like .mean(dim="time") or .max(dim="time").
Collapsing time¶
You can collapse the entire time dimension into a single 2D spatial map using standard reduction methods like .mean(dim="time") or .max(dim="time").
# Calculate the historical mean temperature for every pixel
mean_sst = ds.sst.mean(dim="time")
mean_sst.plot(cmap=cmc.batlow, figsize=(9, 4), vmin=0, vmax=30)
plt.title("Historical Mean Sea Surface Temperature")
plt.show()
Reducing multiple dimensions¶
You are not limited to reducing one dimension at a time. You can reduce over multiple dimensions by passing a list of dimension names. For example, to visualize the North to South temperature gradient, we can average over both time and longitude.
# Collapse time and longitude to see the average temperature by latitude
zonal_mean = ds.sst.mean(dim=["time", "lon"])
zonal_mean.plot(figsize=(6, 4))
plt.title("Zonal Mean Sea Surface Temperature")
plt.ylabel("Temperature (°C)")
plt.show()
Reducing all dimensions¶
Furthermore, if no dimension is specified, xarray reduces across all dimensions, yielding a single global scalar value.
# Yields a single float representing the global average over all time and space
ds.sst.mean()2. Resampling: Changing Time Frequency¶
Environmental observations often come in one temporal frequency, but your analysis needs another. Often, you will receive data at a frequency that is too granular for your analysis. Resampling means changing the time frequency of data, usually reducing to a coarser frequency. For example, converting daily precipitation into monthly totals, or monthly temperatures into yearly averages.
This operation can be thought of as a groupby operation where each group is a chronological block of time. Resampling only works with proper datetime64 coordinate labels.
Monthly to yearly¶
The ERSST dataset is monthly. We can aggregate it into annual means using the .resample() method with specific frequency strings like time="YE" for Year End.
To make the effect of resampling visible, let us first reduce the spatial dimensions and then plot the resulting global time series.
# 1. Resample to yearly means
sst_yearly = ds.sst.resample(time="YE").mean()
# 2. Reduce the spatial dimensions to get a global average
sst_yearly_global = sst_yearly.mean(dim=["lat", "lon"])
# 3. Plot the result
sst_yearly_global.plot(figsize=(10, 4))
plt.title("Yearly Mean Sea Surface Temperature")
plt.ylabel("Temperature (°C)")
plt.show()
Coarser intervals¶
You can also aggregate into longer blocks, for example using time="5YE" for 5 year intervals.
# Resample to 5-year means and collapse spatially
sst_5year = ds.sst.resample(time="5YE").mean()
sst_5year_global = sst_5year.mean(dim=["lat", "lon"])
sst_5year_global.plot(marker="o", figsize=(10, 4))
plt.title("Five-Year Mean Sea Surface Temperature")
plt.ylabel("Temperature (°C)")
plt.show()
The role of the aggregation statistic¶
The resampling method you append to the end of the operation determines the physical meaning of the result:
Use
.mean()for average conditions (like temperature).Use
.sum()for cumulative totals (like precipitation).Use
.max()for extreme conditions (like peak flood levels).
That choice is scientific, not merely technical.
3. Grouping: Exploring Cyclical Patterns¶
Unlike resampling which changes the temporal frequency, grouping does something different. It reorganizes the data into calendar categories that already exist inside the time coordinate. For instance, gathering all the Januaries or all the summers together.
This enables you to ask questions such as: What is the typical January temperature? Or how do the four seasons compare?
The datetime accessor¶
To do this, we can use the Datetime Accessor (.dt) to easily extract specific components of dates and times, such as month or season.
# Extract the month (1-12) or the season (DJF, MAM, JJA, SON)
print(ds.time.dt.month.values[:12])
print(ds.time.dt.season.values[:12])[ 1 2 3 4 5 6 7 8 9 10 11 12]
['DJF' 'DJF' 'MAM' 'MAM' 'MAM' 'JJA' 'JJA' 'JJA' 'SON' 'SON' 'SON' 'DJF']
Grouping by month¶
We can demonstrate the split step using .groupby("time.month"). To complete the workflow, we immediately chain the .mean() method to apply the calculation and combine the results into a new dataset.
# Split the data into 12 monthly groups, apply the mean, and combine
sst_by_month = ds.sst.groupby("time.month").mean()
# Plot the average January temperature (index 0)
sst_by_month.isel(month=0).plot(cmap="cmc.batlow", vmin=0, vmax=30, figsize=(9, 4))
plt.title("Mean Sea Surface Temperature in January")
plt.show()
Grouping by season¶
Grouping is the natural entry point for climatological thinking. We can easily group by season and use xarray’s built-in faceting to plot all four seasons side by side.
# Group by the four meteorological seasons
sst_by_season = ds.sst.groupby("time.season").mean()
sst_by_season.plot(
col="season", col_wrap=2, cmap="cmc.batlow", vmin=0, vmax=30, figsize=(10, 6)
)
plt.show()
Grouping versus Resampling¶
This distinction is essential to understand before moving forward:
ds.resample(time="YE")creates chronological bins and preserves a continuous datetime format.ds.groupby("time.month")creates category labels such as1, 2, ..., 12.ds.groupby("time.season")creates category strings such asDJF,MAM,JJA,SON.
Concept Check: Reorganizing the Timeline¶
You have 20 years of daily precipitation data. You want to generate a continuous timeline of monthly precipitation totals (e.g., Jan 2000, Feb 2000, Mar 2000, etc.) to plot the historical record. Which operation is the correct choice?
A) .resample(time="1ME")
B) .groupby("time.month")
C) .rolling(time=30)
Check your understanding
Answer: A. .resample(time="1ME") Resampling groups data into continuous, chronological time blocks (Jan 2000, Feb 2000, etc.) and preserves the timeline. Grouping (.groupby) would collapse all 20 years into just 12 categorical months (All Januaries averaged together).
4. Climatologies and Anomalies¶
A useful application of the split, apply, combine workflow is generating climate anomalies. We often want to remove the overwhelming seasonal cycle (called the “climatology”) in order to better see long term variations in temperature.
Defining what is normal¶
A climatology is a baseline average, usually defined over a long reference period. It answers the question: What is typical for this month or season? Let us define a baseline period from 1971 to 2000 and calculate the monthly climatology using gb.mean().
# 1. Isolate the baseline period
baseline = ds.sst.sel(time=slice("1971", "2000"))
# 2. Group by month and calculate the mean
monthly_climatology = baseline.groupby("time.month").mean()
# 3. Inspect the baseline for January
monthly_climatology.sel(month=1).plot(
cmap="cmc.batlow", vmin=0, vmax=30, figsize=(9, 4)
)
plt.title("January Climatology (1971–2000)")
plt.show()
Departures from the baseline¶
Once you have a climatology, you can calculate anomalies. An anomaly is the residual difference between the observed value and the expected value for that calendar month.
Removing the seasonal climatology to examine the residual anomaly is a perfect example of a transformation. Xarray makes these transformations easy by supporting groupby arithmetic, allowing us to subtract the grouped mean directly from the original grouped object.
# Subtract the baseline climatology from the original observations
sst_anom = ds.sst.groupby("time.month") - monthly_climatology
# Inspect a single anomaly map for a recent month
anom_example = sst_anom.sel(time="2016-01-01", method="nearest")
anom_example.plot(cmap="RdBu_r", vmin=-3, vmax=3, figsize=(9, 4))
plt.title("Sea Surface Temperature Anomaly (January 2016)")
plt.show()
If you are interested in long term change, raw monthly values can hide the warming signal behind strong annual summer and winter oscillations. Anomalies isolate exactly what is unusual relative to the baseline, making them the most critical tool in climate analysis.
5. Rolling windows and smoothing¶
Even after removing the seasonal cycle, anomaly time series can still be quite noisy. High frequency variability (like short-term weather patterns) can obscure the long term climate signal.
A common solution is to apply a rolling mean using xarray’s built-in .rolling() method, which creates sliding windows of fixed length.
A 12 month rolling mean¶
Let us create a simple global mean anomaly series and smooth it with a 12 month rolling window. We use center=True to ensure the moving average is centered on the current timestamp rather than trailing it.
# 1. Create a raw global mean anomaly series
sst_anom_global = sst_anom.mean(dim=["lat", "lon"])
# 2. Smooth it using a 12-month sliding window
sst_anom_roll12 = sst_anom_global.rolling(time=12, center=True).mean()
# 3. Plot both to see the effect
fig, ax = plt.subplots(figsize=(10, 4))
sst_anom_global.plot(ax=ax, alpha=0.4, label="Raw Monthly Anomaly")
sst_anom_roll12.plot(ax=ax, color="black", linewidth=2, label="12-Month Rolling Mean")
ax.set_title("Global Mean SST Anomaly")
ax.set_ylabel("Temperature Anomaly (°C)")
ax.legend()
plt.show()
Rolling versus Resampling¶
It is important to understand what a rolling window does differently:
.resample()creates entirely new, coarser time bins (e.g., jumping from month to month)..rolling()smooths the data without changing the fundamental time frequency. It keeps the exact same time axis, padding the boundaries withNaNs if necessary.
Interactive Explorer: Climate Anomalies & Rolling Means.
Toggle the data layers to observe how subtracting the seasonal climatology exposes the underlying temperature anomaly. Adjust the rolling mean window slider to smooth out high-frequency weather noise and clearly isolate the long-term climate warming trend. For improved visibility of the explorer, follow this link.
6. Spatial Weighting¶
So far, we have discussed calculating global mean anomalies using a simple spatial average over latitude and longitude. While that is easy, it is not fully correct for a regular lat lon grid.
Because grid cells near the poles cover less physical area than cells near the equator, a simple mean overrepresents the polar regions. If every cell contributes equally, your global temperature will be skewed.
Latitude based weights¶
To fix this, a standard correction is to weight the pixels by the cosine of their latitude. These weights will naturally be largest near the equator and smallest near the poles.
Xarray provides a .weighted() mechanism that creates a special DataArrayWeighted object, allowing you to apply reduction operations accurately.
# 1. Create a weights array proportional to the cosine of latitude
weights = np.cos(np.deg2rad(ds.lat))
# 2. Apply the weights to our monthly anomalies and collapse spatially
sst_anom_weighted = sst_anom.weighted(weights).mean(dim=["lat", "lon"])
# 3. Apply a 12 month rolling mean to the resulting 1D time series
sst_anom_weighted_roll12 = sst_anom_weighted.rolling(time=12, center=True).mean()
# 4. Plot the results
fig, ax = plt.subplots(figsize=(10, 4))
sst_anom_weighted.plot(ax=ax, alpha=0.35, label="Weighted Monthly Anomaly")
sst_anom_weighted_roll12.plot(
ax=ax, color="black", linewidth=2, label="Weighted 12 Month Rolling Mean"
)
ax.set_title("Area-Weighted Global SST Anomaly")
ax.set_ylabel("Temperature Anomaly (°C)")
ax.legend()
plt.show()
For very small local regions, weighting may not change the results significantly. However, for large regional or global summaries, it matters immensely.
7. Visualizing Anomalies¶
Temporal aggregation and groupby arithmetic become much easier to interpret once you visualize the results. These plots are not different datasets; they are simply different reorganizations of the exact same data cube, each highlighting a specific aspect of the Earth’s temporal behavior.
The global seasonal cycle¶
To understand the baseline we established earlier, we can plot the global average of our monthly climatology. Because we are aggregating over a large geographic area (the entire globe) to create a 1D time series, we must remember to apply our spatial weights!
# 1. Apply the spatial weights to our climatology
weighted_climatology = monthly_climatology.weighted(weights)
# 2. Calculate the global mean and plot the seasonal profile
weighted_climatology.mean(dim=["lat", "lon"]).plot(marker="o", figsize=(10, 4))
plt.title("Global Mean Monthly Climatology (1971–2000)")
plt.ylabel("Temperature (°C)")
plt.xlabel("Month")
plt.xticks(range(1, 13)) # Ensure all 12 months are labeled
plt.grid(alpha=0.3)
plt.show()
Tracking anomalies over time¶
While 1D global means are great for overall trends, we often want to see exactly where extreme events are happening. To see how anomalies shift geographically across a specific year, we can use xarray’s built-in faceting.
# Isolate the anomaly data for a single highly anomalous year
anom_2016 = sst_anom.sel(time="2016")
# Plot all 12 months in a grid
anom_2016.plot(
col="time",
col_wrap=4,
cmap="RdBu_r",
vmin=-3,
vmax=3,
figsize=(12, 8),
cbar_kwargs={"label": "SST Anomaly (°C)"},
)
plt.show()
8. Exercise: The Long Term Signal¶
Use the NOAA ersstv5 dataset to bring all the concepts together and visualize the long term warming signal clearly.
Your Task:
Baseline: Calculate a monthly climatology over the baseline period 1971 to 2000.
Anomalies: Compute monthly anomalies by subtracting the climatology from the full series.
Weight: Compute cosine latitude weights.
Aggregate: Calculate an area weighted global mean anomaly time series.
Smooth: Apply a 60 month (5 year) rolling mean to smooth the anomaly time series.
Plot: Create a plot that shows both the raw weighted monthly anomaly (faded in the background) and the final smoothed time series (bolded on top).
Interpret: Write two short sentences explaining what becomes visible once the seasonal cycle has been removed.
Starter code:
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
ds = xr.tutorial.open_dataset("ersstv5")
# 1. Baseline climatology (1971 to 2000)
# ...
# 2. Monthly anomalies
# ...
# 3. Latitude based weights
# ...
# 4. Area weighted global mean anomaly
# ...
# 5. Five year rolling mean (60 months)
# ...
# 6. Plot
# ...Sample solution
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
ds = xr.tutorial.open_dataset("ersstv5")
# 1. Baseline climatology
baseline = ds.sst.sel(time=slice("1971", "2000"))
monthly_climatology = baseline.groupby("time.month").mean()
# 2. Monthly anomalies
sst_anom = ds.sst.groupby("time.month") - monthly_climatology
# 3. Latitude based weights
weights = np.cos(np.deg2rad(ds.lat))
# 4. Area weighted global mean anomaly
sst_anom_weighted = sst_anom.weighted(weights).mean(dim=["lat", "lon"])
# 5. Five year rolling mean (60 months)
sst_anom_smooth = sst_anom_weighted.rolling(time=60, center=True).mean()
# 6. Plot
fig, ax = plt.subplots(figsize=(10, 4))
sst_anom_weighted.plot(ax=ax, alpha=0.3, label="Monthly Anomaly")
sst_anom_smooth.plot(ax=ax, color="black", linewidth=2, label="5-Year Rolling Mean")
ax.set_title("Area Weighted Global SST Anomaly")
ax.set_ylabel("Temperature Anomaly (°C)")
ax.axhline(0, color="black", linestyle="--", alpha=0.5)
ax.legend()
plt.grid(alpha=0.3)
plt.show()
Interpretation:
The seasonal cycle is no longer dominating the series, so the long term warming signal becomes much easier to see. The rolling mean filters out short term variability and reveals a clear, accelerating upward trajectory through time.
9. Summary¶
By manipulating the time dimension in xarray, we can transition from raw, noisy observations to clear climate signals.
Temporal Reductions: Time is not just a list of bands; it is a fully indexable, date-aware dimension that can be collapsed and reduced exactly like latitude or longitude.
Resampling vs. Grouping: This distinction is critical. Use
.resample()to change the chronological frequency of your data (e.g., monthly to yearly). Use.groupby()alongside the.dtaccessor to extract cyclical patterns (e.g., evaluating all Januaries) using the split, apply, combine workflow.Climatologies and Anomalies: A climatology establishes a reference baseline of what is “normal.” Groupby arithmetic (
gb - gb.mean()) is the standard, elegant method for subtracting this baseline to calculate climate anomalies.Smoothing Trends: Use
.rolling()to apply sliding windows that filter out high-frequency weather noise and reveal long-term climate trends without altering the underlying time axis.Spatial Weighting: Never plot global or regional time series aggregated from a regular lat-lon grid without first applying spatial weights (like the cosine of latitude) to mathematically correct for the Earth’s spherical geometry.