How to Detect Marine Heatwaves in Python

On this page

Problem statement

A marine heatwave is a period of unusually warm sea water, but "unusual" needs a definition before it can be counted, mapped or compared. The one most studies use, from Hobday and colleagues (2016), defines it against the local seasonal cycle: at least five consecutive days above the 90th percentile of temperatures for that time of year, with a 30-year baseline. Implementing it takes a daily climatology with a moving window, a smoothed threshold, run detection that joins short breaks, and the intensity categories that classify each event.

This page builds that from scratch with pandas on 42 years of daily OISST for a box off the west coast of Ireland, finds the 2023 events, classifies them, and counts marine heatwave days per year since 1982.

Quick answer

Compute a day-of-year 90th percentile from the baseline, mark days above it, and keep runs of five days or more:

clim, thresh = climatology(T, "1991-01-01", "2020-12-31")
hot = T > thresh
ev = events(hot, min_days=5, max_gap=2)
y23 = ev[ev.start.dt.year == 2023]
print(f"{len(ev)} events 1982-2023; in 2023: {len(y23)}")
print(y23.assign(peak=[float((T - clim)[s:e].max()) for s, e in zip(y23.start, y23.end)]).to_string(
    index=False, formatters={"start": "{:%d %b}".format, "end": "{:%d %b}".format, "peak": "{:+.2f} °C".format}))
88 events 1982-2023; in 2023: 6
 start    end  days     peak
25 Feb 03 Mar     7 +0.69 °C
15 Mar 02 Apr    19 +0.81 °C
01 May 13 May    13 +1.25 °C
24 May 06 Jul    44 +3.74 °C
01 Sep 22 Sep    22 +2.14 °C
23 Oct 09 Nov    18 +1.58 °C

The method finds 88 marine heatwaves in 42 years, six of them in 2023. The one that started on 24 May lasted 44 days to 6 July and peaked 3.74 °C above the seasonal normal, during the record north-east Atlantic heatwave of June 2023.

Chart of 2023 off western Ireland: the box-mean sea surface temperature, the 1991 to 2020 climatology rising from about 9.7 degrees in March to 15.6 in August, and the 90th percentile threshold above it; shaded events show a severe heatwave from late May to early July peaking near 17.5 degrees, a strong one in September and moderate ones in March, May and November. Chart of 2023 off western Ireland: the box-mean sea surface temperature, the 1991 to 2020 climatology rising from about 9.7 degrees in March to 15.6 in August, and the 90th percentile threshold above it; shaded events show a severe heatwave from late May to early July peaking near 17.5 degrees, a strong one in September and moderate ones in March, May and November.
Marine heatwaves in 2023.

Climatology and threshold, hot days into events, categories

1. The climatology and the threshold

For each day of the year, pool the baseline temperatures within five days either side, take the mean and the 90th percentile, and smooth both with a 31-day moving average:

doy = T.index.dayofyear.where(~(T.index.is_leap_year & (T.index.dayofyear > 59)), T.index.dayofyear - 1)  # 365-day calendar
doy = pd.Series(np.where((T.index.month == 2) & (T.index.day == 29), 59, doy), index=T.index)
print(f"days in the series: {len(T):,}; baseline days: {int(T['1991':'2020'].count()):,}")
print(f"climatology: coldest {clim.min():.2f} °C, warmest {clim.max():.2f} °C; threshold above climatology by {(thresh - clim).min():.2f} to {(thresh - clim).max():.2f} °C")
print(f"days above the threshold, all years: {int(hot.sum()):,} ({hot.mean():.1%}); in the baseline: {hot['1991':'2020'].mean():.1%}")
days in the series: 15,340; baseline days: 10,958
climatology: coldest 9.66 °C, warmest 15.60 °C; threshold above climatology by 0.58 to 1.32 °C
days above the threshold, all years: 1,474 (9.6%); in the baseline: 9.3%

The climatology runs from 9.66 °C in late winter to 15.60 °C in late summer, and the threshold sits 0.58 to 1.32 °C above it, furthest in summer, when temperatures vary most from year to year. In the baseline 9.3% of days exceed it, close to the 10% a 90th percentile implies; the smoothing and the pooled window account for the difference.

2. From hot days to events

Runs shorter than five days are not events; two events separated by a gap of two days or less are one:

runs = (hot != hot.shift()).cumsum()[hot]
lengths = runs.value_counts()
print(f"runs of hot days: {len(lengths):,}; of 5 days or more: {(lengths >= 5).sum()}; after joining gaps of up to 2 days: {len(ev)}")
print(f"longest event: {ev.days.max()} days, from {ev.loc[ev.days.idxmax(), 'start']:%d %B %Y}")
runs of hot days: 244; of 5 days or more: 99; after joining gaps of up to 2 days: 88
longest event: 51 days, from 31 July 1995

Of 244 runs of hot days, 99 last five days or more, and joining runs separated by up to two days leaves 88 events. The longest is not from 2023 but from the summer of 1995: 51 days from 31 July.

Detecting marine heatwaves with the Hobday et al. definition. from a daily series to marine heatwavesdaily SST1982-2023, completedaysday-of-yearpool+-5 days, 1991-2020mean, 90th pctsmoothed over 31daysruns of 5+daysgaps of 2 or fewerjoinedcategorisepeak / (threshold -mean)Hobday et al. (2016, 2018) spatialworkflow.io
From a daily series to heatwaves.

3. Categories

Each event is classified by its peak intensity, in multiples of the local difference between the threshold and the climatology: moderate below 2, strong from 2, severe from 3, extreme from 4:

def category(s, e):
    ratio = ((T - clim) / (thresh - clim))[s:e].max()
    return ["moderate", "strong", "severe", "extreme"][min(int(ratio) - 1, 3)] if ratio >= 1 else "moderate"
ev["category"] = [category(s, e) for s, e in zip(ev.start, ev.end)]
print(ev.category.value_counts().reindex(["moderate", "strong", "severe", "extreme"], fill_value=0).to_string())
print(ev[ev.start.dt.year == 2023][["start", "days", "category"]].to_string(index=False, formatters={"start": "{:%d %b %Y}".format}))
category
moderate    68
strong      16
severe       4
extreme      0
      start  days category
25 Feb 2023     7 moderate
15 Mar 2023    19 moderate
01 May 2023    13 moderate
24 May 2023    44   severe
01 Sep 2023    22   strong
23 Oct 2023    18 moderate

68 of the 88 events are moderate, 16 strong and 4 severe; none reaches extreme. The June 2023 event is one of the four severe ones, peaking at more than three times the local gap between the threshold and the mean.

Marine heatwave days per year

The data used on this page

Daily NOAA OISST from 1 January 1982 to 31 December 2023 for 52°N to 54°N and 12°W to 10°W, averaged over the box with cosine-of-latitude weights. ERDDAP aggregations can miss days, and a day they lack comes back as its neighbour, so the setup takes each year from NOAA CoastWatch's ERDDAP — the version 2 aggregation to 2015 (NCEI states that v2 and v2.1 are the same before 2016), v2.1 after — fills every day it lacks from NCEI's daily files, and prints any year still short; the missing days fix shows why. The definition follows Hobday et al. (2016), Progress in Oceanography 141, with the 1991–2020 baseline.

import io
import time
from pathlib import Path

import numpy as np
import pandas as pd
import requests
import xarray as xr

Path("data").mkdir(exist_ok=True)
CW = "https://coastwatch.pfeg.noaa.gov/erddap/griddap"
ARCHIVE = "https://www.ncei.noaa.gov/data/sea-surface-temperature-optimum-interpolation/v2.1/access/avhrr"
def _get(url):
    for attempt in range(5):                                            # a busy ERDDAP answers 500 or 503
        r = requests.get(url, timeout=900)
        if r.status_code < 500:
            break
        time.sleep(30 * (attempt + 1))
    r.raise_for_status()
    return r
def _lon180(da):
    return da.assign_coords(longitude=(da.longitude + 180) % 360 - 180).sortby("longitude")
def oisst(start, end, path, var="sst", lat=(52, 54), lon=(-12, -10), step=1):
    """Daily OISST v2.1 for a box and dates, saved to path, longitudes -180 to 180. CoastWatch's ERDDAP
    aggregation (version 2 before 2016, the same analysis as v2.1 for those years), with every day it
    lacks taken from NCEI's daily archive: the aggregations skip days, and a skipped day is silent."""
    path = Path(path)
    if not path.exists():
        days = pd.date_range(start, end)
        ds = "ncdcOisst2Agg" if days[-1].year <= 2015 else "ncdcOisst21Agg"
        a, b = lon[0] % 360, lon[1] % 360
        got = []
        for lo in [(a, b)] if a <= b else [(a, 359.875), (0.125, b)]:          # across 0°, two requests
            q = (f"{var}[({start}T12:00:00Z):1:({end}T12:00:00Z)][(0.0)]"
                 f"[({lat[0]}):{step}:({lat[1]})][({lo[0]}):{step}:({lo[1]})]")
            got.append(xr.open_dataset(io.BytesIO(_get(f"{CW}/{ds}.nc?{q}").content))[var].load())
        da = _lon180(xr.concat(got, "longitude").squeeze("zlev", drop=True))
        da = da.sel(time=slice(start, f"{end}T23:59"))                       # a missing end day snaps past it
        for day in days.difference(pd.DatetimeIndex(da.time.values).normalize()):
            url = f"{ARCHIVE}/{day:%Y%m}/oisst-avhrr-v02r01.{day:%Y%m%d}.nc"
            d = xr.open_dataset(io.BytesIO(_get(url).content))[var].squeeze("zlev", drop=True)
            d = _lon180(d.rename(lat="latitude", lon="longitude"))
            d = d.sel(latitude=da.latitude, longitude=da.longitude, method="nearest")
            da = xr.concat([da, d.assign_coords(latitude=da.latitude, longitude=da.longitude).load()], "time")
        da.sortby("time").to_dataset(name=var).to_netcdf(path)
    return xr.open_dataset(path)[var]
def oisst_year(year, var="sst", lat=(52, 54), lon=(-12, -10), tag="ireland_west"):
    return oisst(f"{year}-01-01", f"{year}-12-31", f"data/oisst_{tag}_{var}_{year}.nc", var, lat, lon)
da = xr.concat([oisst_year(y) for y in range(1982, 2024)], "time")
T = da.weighted(np.cos(np.deg2rad(da.latitude))).mean(["latitude", "longitude"]).to_series()
T.index = T.index.normalize()
def climatology(t, start, end, window=5, smooth=31):
    """Day-of-year mean and 90th percentile over a baseline, with a +-window pool and a smoothing window, on a 365-day calendar."""
    d = t.index.dayofyear - ((t.index.is_leap_year) & (t.index.dayofyear > 59)).astype(int)
    d = np.where((t.index.month == 2) & (t.index.day == 29), 59, d)
    base = pd.Series(t.values, index=d)[(t.index >= start) & (t.index <= end)]
    m, p = {}, {}
    for k in range(1, 366):
        near = [(k + o - 1) % 365 + 1 for o in range(-window, window + 1)]
        v = base[base.index.isin(near)]
        m[k], p[k] = v.mean(), v.quantile(0.9)
    sm = lambda s: pd.concat([s.iloc[-smooth:], s, s.iloc[:smooth]]).rolling(smooth, center=True).mean().iloc[smooth:-smooth]
    m, p = sm(pd.Series(m)), sm(pd.Series(p))
    return pd.Series(m.loc[d].values, index=t.index), pd.Series(p.loc[d].values, index=t.index)
def events(hot, min_days=5, max_gap=2):
    """Start, end and length of runs of hot days lasting min_days or more, joined across gaps of max_gap days or fewer."""
    r = (hot != hot.shift()).cumsum()
    runs = [(g.index[0], g.index[-1]) for _, g in hot[hot].groupby(r[hot]) if len(g) >= min_days]
    joined = []
    for s, e in runs:
        if joined and (s - joined[-1][1]).days - 1 <= max_gap:
            joined[-1] = (joined[-1][0], e)
        else:
            joined.append((s, e))
    return pd.DataFrame({"start": [s for s, _ in joined], "end": [e for _, e in joined], "days": [(e - s).days + 1 for s, e in joined]})
per_year = T.groupby(T.index.year).size()
expected = pd.Series([366 if pd.Timestamp(y, 1, 1).is_leap_year else 365 for y in per_year.index], index=per_year.index)
short = (expected - per_year)[per_year < expected]
print(f"{da.sizes['latitude']} x {da.sizes['longitude']} cells, {len(T):,} days from {T.index[0]:%Y-%m-%d} to {T.index[-1]:%Y-%m-%d}")
print(f"years with missing days: {({int(k): int(v) for k, v in short.items()}) if len(short) else 'none'}")
9 x 9 cells, 15,340 days from 1982-01-01 to 2023-12-31
years with missing days: none

The box is 9 by 9 cells, and the series has all 15,340 days from 1982 to 2023: the two days of 2011 that CoastWatch's version 2 aggregation lacks came from NCEI's daily files.

Example 1: marine heatwave days per year

Count the days inside events in each year and compare the decades:

inside = pd.Series(False, index=T.index)
for s, e in zip(ev.start, ev.end):
    inside[s:e] = True
per_year = inside.groupby(inside.index.year).sum()
dec = per_year.groupby((per_year.index // 10) * 10).mean().round(0).astype(int)
dec.index = [f"{d}s" for d in dec.index]
print(dec.rename("mean MHW days per year").to_string())
print(f"most: {per_year.idxmax()} with {per_year.max()} days; years with none: {(per_year == 0).sum()}")
1980s    14
1990s    19
2000s    39
2010s    21
2020s    68
most: 2023 with 137 days; years with none: 9

Marine heatwave days have risen from 14 a year in the 1980s to 68 a year in 2020–2023 against the same 1991–2020 baseline, and 2023 had the most, 137 days. Nine years had none. Part of the rise is the warming trend itself, as the explainer measures by detrending the series.

Bar chart of marine heatwave days per year off western Ireland from 1982 to 2023 with decade means: about 14 days a year in the 1980s, 19 in the 1990s, 39 in the 2000s, 21 in the 2010s and 68 in 2020 to 2023; 2023 is highest with 137 days, and several years have none. Bar chart of marine heatwave days per year off western Ireland from 1982 to 2023 with decade means: about 14 days a year in the 1980s, 19 in the 1990s, 39 in the 2000s, 21 in the 2010s and 68 in 2020 to 2023; 2023 is highest with 137 days, and several years have none.
Heatwave days per year.

A definition relative to season and place

Marine heatwave categories by peak intensity relative to the local threshold distance. categories: peak as a multiple of (threshold - mean)IV extreme4x or moreIII severe3x to 4xII strong2x to 3xI moderate1x to 2xOff western Ireland the threshold sits 0.58 to 1.32 °C above the mean spatialworkflow.io
Categories.

The definition is relative in two ways. It compares each day with the same time of year, so a warm winter can be a heatwave while being colder than an ordinary summer; and it uses the local 90th percentile, so the same rule works in the tropics and at high latitudes. The 11-day window pools enough days to estimate a percentile from 30 years; the 31-day smoothing removes the noise that would otherwise put kinks into the threshold. The five-day minimum separates heatwaves from a few warm days, and joining across two-day gaps keeps one long event from being counted as several.

The categories, introduced by Hobday and colleagues in 2018, scale intensity by the local variability: the distance between the 90th percentile and the mean. An event that peaks twice that distance above the climatology is strong, three times severe, four times extreme. A fixed baseline makes warming visible as more heatwave days; a moving baseline, as some studies use, measures departures from recent conditions instead. Which baseline was used changes the counts and should always be stated.

Leap days, boxes and baselines

Checks before trusting a marine heatwave count. before counting marine heatwavesevery day presentgaps break runs365-day calendar29 February mapped to 28baseline stated1991-2020, fixedbox or cellbox means smooth eventssame code for all yearscompare like with likeOISST, 52-54 N, 12-10 W spatialworkflow.io
Before counting marine heatwaves.
  • Leap days: a day-of-year index shifts every date after 28 February by one in leap years; map dates to a 365-day calendar, as here, so 1 March is always day 60.
  • Box or cell: averaging a box first smooths the series and finds fewer, longer events than running the method on each cell; say which was done.
  • Baseline period: Hobday et al. suggest 30 years; the original paper used 1983–2012, recent work 1991–2020.
  • Packages: marineHeatWaves (Python) and heatwaveR (R) implement the same definition with more options; a from-scratch version is a good check on them.

FAQ

How is a marine heatwave defined?

By Hobday et al. (2016): at least five consecutive days with sea surface temperature above the seasonally varying 90th percentile of a 30-year baseline, with gaps of two days or less joined.

How do I detect marine heatwaves in Python?

Compute a day-of-year climatology and 90th percentile with an 11-day window and 31-day smoothing, flag days above the threshold, find runs of five or more days, and join runs separated by two days or less.

What are marine heatwave categories?

Moderate, strong, severe and extreme, for peak intensities of one, two, three and four times the difference between the 90th percentile threshold and the climatological mean.

Was 2023 a marine heatwave year off Ireland?

Yes: the 2023 events for this box are listed in the quick answer and in step 3, including the June event during the record north-east Atlantic heatwave.