Chris Parmer — home

Climate spaghetti chart

GHCN-Daily annual temperature anomalies, grey lines + bold current year

Example from the compendium of canonical charts

Climate spaghetti chart — GHCN-Daily annual temperature anomalies, grey lines + bold current year

Python Code

"""Climate spaghetti chart — GHCN-Daily annual temperature anomalies, grey lines + bold current year."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go
import requests, warnings, io, gzip


# NOAA GHCN-Daily: Central England Temperature (Parker 1992) — annual anomalies
# CET annual mean temperature 1659-present (easy CSV)
CET_URL = "https://www.metoffice.gov.uk/hadobs/hadcet/cetml1659on.dat"

# Fallback: GISS Surface Temperature anomaly (NASA)
GISS_URL = "https://data.giss.nasa.gov/gistemp/tabledata_v4/GLB.Ts+dSST.csv"


def parse_cet(text):
    """CET annual data: year followed by monthly values + annual mean."""
    rows = []
    for line in text.strip().split("\n"):
        line = line.strip()
        if not line or line.startswith("C") or line.startswith("Y"):
            continue
        parts = line.split()
        if len(parts) < 2:
            continue
        try:
            year = int(parts[0])
            # Monthly values then annual; -99.9 = missing
            vals = [float(v) for v in parts[1:] if v not in ("-99.9", "---", "")]
            if vals:
                rows.append({"year": year, "monthly": vals[:12]})
        except ValueError:
            continue
    return rows


def generate():
    print("fetching climate temperature data …")
    warnings.filterwarnings("ignore")
    try:
        requests.packages.urllib3.disable_warnings()
    except Exception:
        pass

    df_annual = None
    source = None

    # Try GISS global surface temperature
    try:
        r = requests.get(GISS_URL, verify=False, timeout=30)
        if r.status_code == 200:
            text = r.text
            # GISS CSV has a header line then annual anomalies
            lines = text.split("\n")
            # Find data start: first line starting with 4-digit year
            data_lines = [l for l in lines if l and l[0].isdigit() and len(l.split(",")) > 1]
            if data_lines:
                from io import StringIO
                df_raw = pd.read_csv(StringIO("\n".join(lines)), skiprows=1, na_values=["***", "****"])
                df_raw.columns = [c.strip() for c in df_raw.columns]
                year_col = df_raw.columns[0]
                # Find annual or J-D column
                ann_col = next((c for c in df_raw.columns if c in ("J-D", "Annual", "annual")), None)
                if ann_col:
                    df_annual = pd.DataFrame({
                        "year": pd.to_numeric(df_raw[year_col], errors="coerce"),
                        "anomaly": pd.to_numeric(df_raw[ann_col], errors="coerce"),
                    }).dropna()
                    source = "NASA GISS Global Surface Temperature"
                    print(f"  GISS: {len(df_annual)} years")
    except Exception as e:
        print(f"  GISS failed: {e}")

    # Try CET
    if df_annual is None or len(df_annual) < 50:
        try:
            r = requests.get(CET_URL, verify=False, timeout=30)
            if r.status_code == 200:
                rows = parse_cet(r.text)
                if rows:
                    # Annual mean = mean of monthly vals
                    annual_means = []
                    for row in rows:
                        vals = [v for v in row["monthly"] if v > -90]
                        if vals:
                            annual_means.append({"year": row["year"], "mean": np.mean(vals)})
                    df_temp = pd.DataFrame(annual_means)
                    # Compute anomaly vs 1961-1990 baseline
                    baseline = df_temp[(df_temp["year"] >= 1961) & (df_temp["year"] <= 1990)]["mean"].mean()
                    df_annual = pd.DataFrame({
                        "year": df_temp["year"],
                        "anomaly": df_temp["mean"] - baseline,
                    })
                    source = "Central England Temperature (CET) anomaly vs 1961-1990"
                    print(f"  CET: {len(df_annual)} years, baseline={baseline:.2f}°C")
        except Exception as e:
            print(f"  CET failed: {e}")

    if df_annual is None or len(df_annual) < 20:
        print("  using synthetic climate data")
        rng = np.random.default_rng(36)
        years = np.arange(1880, 2026)
        trend = (years - 1880) * 0.012   # ~1.8°C warming over 145 years
        anomaly = trend + rng.normal(0, 0.15, len(years))
        df_annual = pd.DataFrame({"year": years, "anomaly": anomaly})
        source = "Synthetic global temperature anomaly"

    df_annual = df_annual.sort_values("year").reset_index(drop=True)
    print(f"  total: {len(df_annual)} annual values, {df_annual['year'].min()}–{df_annual['year'].max()}")

    years      = df_annual["year"].astype(int).values
    anomalies  = df_annual["anomaly"].values
    current_yr = years.max()

    # Compute 10-year rolling mean for the "climate signal" line
    df_annual["rolling10"] = df_annual["anomaly"].rolling(10, min_periods=5, center=True).mean()

    fig = go.Figure()

    # Thin grey lines for each decade
    # (Group into ~30-year windows so the chart isn't overwhelming)
    decade_groups = {
        "pre-1950": (years < 1950),
        "1950–1979": (years >= 1950) & (years < 1980),
        "1980–2009": (years >= 1980) & (years < 2010),
        "2010–present": (years >= 2010) & (years < current_yr),
    }

    for label, mask in decade_groups.items():
        if mask.sum() == 0:
            continue
        fig.add_trace(go.Scatter(
            x=years[mask],
            y=anomalies[mask],
            mode="lines",
            name=label,
            line=dict(color=GRID, width=3),
            opacity=0.6,
            legendgroup="annual",
            showlegend=False,
            hovertemplate="%{x}: %{y:+.2f}°C<extra></extra>",
        ))

    # 10-year rolling average
    rolling = df_annual["rolling10"].values
    valid   = ~np.isnan(rolling)
    fig.add_trace(go.Scatter(
        x=years[valid],
        y=rolling[valid],
        mode="lines",
        name="10-yr rolling mean",
        line=dict(color=ORANGE, width=2.5),
        hovertemplate="Rolling mean %{x}: %{y:+.2f}°C<extra></extra>",
    ))

    # Current year bold line (single point highlight)
    cur_mask = years == current_yr
    if cur_mask.sum():
        fig.add_trace(go.Scatter(
            x=years[cur_mask],
            y=anomalies[cur_mask],
            mode="markers",
            name=f"{current_yr}",
            marker=dict(color=PINK, size=10, symbol="star"),
            hovertemplate=f"{current_yr}: %{{y:+.2f}}°C<extra></extra>",
        ))

    # Zero reference line
    fig.add_hline(
        y=0,
        line=dict(color=GRID, width=1, dash="dot"),
    )

    fig.update_layout(
        title=dict(text=f"Annual Temperature Anomaly — {source}", x=0.5),
        xaxis=dict(title="Year", showgrid=False),
        yaxis=dict(title="Anomaly (°C vs baseline)", showgrid=False),
        legend=dict(orientation="h", y=1.08),
        margin=dict(t=60, b=50, l=70, r=40),
        height=460,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly