Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

07 — Figure 2 replica

LifeWatch ERIC

Reproduces Oliver et al. (2018) Fig. 2, “Total number of marine heatwave days globally”, from ESA SST CCI v3.0 + XMHW instead of NOAA OI SST.

The layout deliberately matches the original — same axes, same colours, same shading scheme — so the two can be laid side by side and judged by eye. A reader cannot evaluate “31.77 versus 30” at a glance, but they can evaluate two curves.

Colours are sampled from the paper’s own figure at 200 dpi (pdftoppm -r 200), not eyeballed:

elementcolour
global average line#000000
excluding-ENSO line#ed3c3c
El Niño shading#f3b9b9
La Niña shading#b3b3f3

Verified colour-vision safe (pixi run check-colors): worst separation ΔE 36.9 under deuteranopia, protanopia or tritanopia, against a threshold of 20. The palette is safe because it opposes blue and red rather than red and green, so it is reused unchanged.

Matching the design must not extend to matching the identity: a figure outlives its caption, so a provenance line naming the dataset and software is stamped on the face. It should never be mistakable for the original.

import json
import os
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr

TARGET_RES_DEG = float(os.environ.get("MHW_TARGET_RES_DEG", 1.0))
RESULTS_DIR = Path("../results")
FIG_DIR = Path("../figures")
FIG_DIR.mkdir(parents=True, exist_ok=True)

# Sampled from the paper — see the table above.
C_BLACK = "#000000"
C_RED = "#ed3c3c"
C_ELNINO = "#f3b9b9"
C_LANINA = "#b3b3f3"

MEI_THRESHOLD = 1.0   # "exceeding +/-1 s.d. of the MEI index"
MEI_RUN_MONTHS = 3    # "...for three consecutive months"
black = xr.open_dataset(RESULTS_DIR / f"mhw_annual_{TARGET_RES_DEG:g}deg.nc")
red = xr.open_dataset(
    RESULTS_DIR / f"mhw_annual_{TARGET_RES_DEG:g}deg_enso_removed.nc")
cmp_all = json.load(open(RESULTS_DIR / "headline_comparison.json"))

years = black["year"].values
days_all = black["global_mhw_days"].values
days_noenso = red["global_mhw_days"].values
print(f"black line: {days_all.min():.1f}..{days_all.max():.1f} days")
print(f"red line  : {days_noenso.min():.1f}..{days_noenso.max():.1f} days")
black line: 12.5..94.1 days
red line  : 11.9..70.4 days

El Niño and La Niña periods

The caption defines them as periods where the MEI exceeds ±1 s.d. for three consecutive months. The MEI is published in units of standard deviations, so the test is directly on its value.

mei = pd.read_csv("../data/raw/mei_original.csv", index_col=0,
                  parse_dates=True)["mei"]
mei = mei[(mei.index.year >= years[0] - 1) & (mei.index.year <= years[-1] + 1)]


def sustained_periods(series: pd.Series, sign: int) -> list[tuple]:
    """Spans where sign*MEI exceeds the threshold for >= MEI_RUN_MONTHS months."""
    hit = (series * sign) > MEI_THRESHOLD
    spans, start, run = [], None, 0
    for stamp, is_hit in hit.items():
        if is_hit:
            start = stamp if run == 0 else start
            run += 1
        else:
            if run >= MEI_RUN_MONTHS:
                spans.append((start, prev))
            start, run = None, 0
        prev = stamp
    if run >= MEI_RUN_MONTHS:
        spans.append((start, prev))
    return spans


el_nino = sustained_periods(mei, +1)
la_nina = sustained_periods(mei, -1)
print(f"{len(el_nino)} El Nino and {len(la_nina)} La Nina periods "
      f"({years[0]}-{years[-1]})")


def to_year(stamp) -> float:
    """Decimal year, so shading aligns with the annual x-axis."""
    return stamp.year + (stamp.dayofyear - 1) / 365.25
10 El Nino and 4 La Nina periods (1982-2016)

The figure

plt.style.use("seaborn-v0_8-whitegrid")
fig, ax = plt.subplots(figsize=(7.2, 4.4))

# Shading first, so the lines sit on top of it.
for spans, colour, label in ((el_nino, C_ELNINO, "El Niño period"),
                             (la_nina, C_LANINA, "La Niña period")):
    for i, (t0, t1) in enumerate(spans):
        ax.axvspan(to_year(t0), to_year(t1), color=colour, lw=0, zorder=1,
                   label=label if i == 0 else None)

ax.plot(years, days_all, color=C_BLACK, lw=1.6, zorder=3, label="Global average")
ax.plot(years, days_noenso, color=C_RED, lw=1.4, zorder=3, label="Excluding ENSO")

ax.set_ylabel("Annual MHW days")
ax.set_xlim(years[0], years[-1])
ax.set_ylim(10, 100)
ax.set_yticks(range(10, 101, 10))
ax.set_xticks([1985, 1990, 1995, 2000, 2005, 2010, 2015])
ax.grid(False)
for side in ("top", "right", "bottom", "left"):
    ax.spines[side].set_visible(True)
    ax.spines[side].set_color("black")

# Legend order follows the paper: two lines, then the two shadings.
handles, labels = ax.get_legend_handles_labels()
order = [labels.index(k) for k in
         ("Global average", "Excluding ENSO", "El Niño period", "La Niña period")
         if k in labels]
ax.legend([handles[i] for i in order], [labels[i] for i in order],
          loc="upper left", frameon=True, framealpha=1.0, edgecolor="black",
          fontsize=9)

prov = cmp_all["replication"]
fig.text(
    0.01, 0.005,
    f"Replication of Oliver et al. (2018) Fig. 2 · ESA SST CCI Analysis v3.0 "
    f"(DOI {prov['dataset_doi']}) · XMHW {prov.get('software_version', '?')} · "
    f"{prov['resolution_deg']:g}° grid, {prov['n_cells']} ocean cells",
    fontsize=6.5, color="0.35",
)

fig.tight_layout(rect=[0, 0.025, 1, 1])
fig.savefig(FIG_DIR / "figure2_replica.png", dpi=200, bbox_inches="tight")
fig.savefig(FIG_DIR / "figure2_replica.pdf", bbox_inches="tight")
plt.show()
<Figure size 720x440 with 1 Axes>
print(f"red below black in {(days_noenso < days_all).sum()}/{len(years)} years; "
      f"mean reduction {np.mean(days_all - days_noenso):.2f} days/yr")
red below black in 34/35 years; mean reduction 6.76 days/yr