Climate spaghetti chart
GHCN-Daily annual temperature anomalies, grey lines + bold current year
Example from the compendium of canonical charts
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