Skip to content

Instantly share code, notes, and snippets.

@cavedave
Last active May 19, 2026 07:49
Show Gist options
  • Select an option

  • Save cavedave/a11fa410a471b4fb50b656e76e3edbe0 to your computer and use it in GitHub Desktop.

Select an option

Save cavedave/a11fa410a471b4fb50b656e76e3edbe0 to your computer and use it in GitHub Desktop.
staircase.ipynb
"""Staircase of Denial — HadCRUT5 annual global anomalies (from notebook gist)."""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
import imageio.v2 as imageio
import matplotlib
matplotlib.use("Agg")
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
HADCRUT_CSV = (
"https://www.metoffice.gov.uk/hadobs/hadcrut5/data/HadCRUT.5.0.2.0/"
"analysis/diagnostics/HadCRUT.5.0.2.0.analysis.summary_series.global.annual.csv"
)
# Shared chart title (two lines). Source line below names HadCRUT5.
CHART_TITLE_LINE1 = "Staircase of denial"
CHART_TITLE_LINE2 = "Global annual temperature anomaly"
def chart_title() -> str:
return f"{CHART_TITLE_LINE1}\n{CHART_TITLE_LINE2}"
def load_series(url: str = HADCRUT_CSV) -> pd.Series:
df = pd.read_csv(url)
df = df.rename(columns={"Time": "Year", "Anomaly (deg C)": "Anomaly"})
df = df.set_index("Year")
return df["Anomaly"]
def compute_steps(ts: pd.Series, start_year: int = 1980, min_plateau_years: int = 3):
post = ts.where(ts.index >= start_year)
current_max = -np.inf
record_years: list[int] = []
record_values: list[float] = []
for yr, val in post.items():
if np.isnan(val):
continue
if val >= current_max:
current_max = val
record_years.append(int(yr))
record_values.append(float(val))
all_steps = list(zip(record_years, record_years[1:], record_values))
steps = [(s, e, y) for s, e, y in all_steps if (e - s) >= min_plateau_years]
limits = (
float(ts.index.min()),
float(ts.index.max()),
float(ts.min()) - 0.1,
float(ts.max()) + 0.16,
)
return steps, limits
def _draw_poster_staircase(ax: plt.Axes, ts: pd.Series, steps: list[tuple[int, int, float]]) -> None:
"""Full staircase with duration labels (same graphic as static PNG)."""
ax.plot(ts.index, ts.values, lw=1.25, color="#4a4a4a", alpha=0.85)
for i, (s, e, y) in enumerate(steps):
ax.hlines(y, s, e, linewidth=3, color="C0")
mid = s + (e - s) / 2
prev_mid = None
if i > 0:
ps, prev_e, _py = steps[i - 1]
prev_mid = ps + (prev_e - ps) / 2
tight_x = prev_mid is not None and (mid - prev_mid) < 14
pad = 0.03 if tight_x else 0.022
above = i % 2 == 0
if above:
yy = y + pad
va = "bottom"
else:
yy = y - pad
va = "top"
txt = ax.text(
mid,
yy,
f"{e - s}yr",
ha="center",
va=va,
fontweight="bold",
fontsize=9 if tight_x else 10,
color="black",
)
txt.set_path_effects([pe.withStroke(linewidth=3, foreground="white")])
def render_gif_hold_poster(
ts: pd.Series,
steps: list[tuple[int, int, float]],
limits: tuple[float, float, float, float],
out_path: Path,
) -> None:
"""One frame matching staircase_static.png, sized like animation frames for the GIF tail."""
xmin, xmax, ymin, ymax = limits
fig, ax = plt.subplots(figsize=(8, 5))
ax.set_position([0.12, 0.15, 0.80, 0.75])
ax.axhline(0, linestyle="--", linewidth=1, alpha=0.3)
_draw_poster_staircase(ax, ts, steps)
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
ax.set_title(chart_title(), fontsize=12, fontweight="bold")
ax.set_xlabel("Year", fontsize=12)
ax.set_ylabel("Temperature anomaly (°C)", fontsize=12)
ax.grid(True, which="major", axis="y", linestyle="--", linewidth=0.5, alpha=0.3)
ax.text(
0.99,
-0.1,
"Source: HadCRUT5 by @iamreddave",
ha="right",
va="top",
transform=ax.transAxes,
fontsize=9,
color="gray",
)
ax.text(
0.20,
-0.1,
"Anomalies relative to 1961-1990",
ha="right",
va="top",
transform=ax.transAxes,
fontsize=9,
color="gray",
)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(out_path, dpi=120)
plt.close(fig)
def render_animation_frames(ts, steps, limits, frames_dir: Path, start_year: int = 1980):
xmin, xmax, ymin, ymax = limits
frames_dir.mkdir(parents=True, exist_ok=True)
frame_paths: list[Path] = []
for year in ts.index:
if int(year) < start_year:
continue
fig, ax = plt.subplots(figsize=(8, 5))
ax.set_position([0.12, 0.15, 0.80, 0.75])
ax.axhline(0, color="black", linestyle="--", linewidth=1, alpha=0.1)
ax.plot(ts.index, ts.values, color="#4a4a4a", lw=1.25, alpha=0.85)
for s, e, y in steps:
if e <= year:
ax.hlines(y, s, e, lw=3, color="C0")
elif s <= year < e:
ax.hlines(y, s, year, lw=4, color="tomato")
ax.text(
year,
y + 0.02,
f"No warming in {int(year) - s} yr",
ha="right",
va="bottom",
color="tomato",
fontsize=11,
fontweight="bold",
)
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
ax.set_title(
chart_title(),
loc="center",
fontsize=11,
fontweight="bold",
pad=6,
)
ax.set_xlabel("Year", fontsize=12)
ax.set_ylabel("Temperature anomaly (°C)", fontsize=12)
ax.text(
0.99,
-0.1,
"Source: HadCRUT5 by @iamreddave",
ha="right",
va="top",
transform=ax.transAxes,
fontsize=9,
color="gray",
)
ax.text(
0.20,
-0.1,
"Anomalies relative to 1961-1990",
ha="right",
va="top",
transform=ax.transAxes,
fontsize=9,
color="gray",
)
out = frames_dir / f"frame_{int(year)}.png"
fig.savefig(out, dpi=120)
plt.close(fig)
frame_paths.append(out)
return frame_paths
def write_gif(
frame_paths: list[Path],
gif_path: Path,
fps: float = 7.0,
hold_last_seconds: float = 3.0,
hold_poster_path: Path | None = None,
) -> None:
gif_path.parent.mkdir(parents=True, exist_ok=True)
hold_frames = max(0, int(round(hold_last_seconds * fps)))
with imageio.get_writer(gif_path, mode="I", fps=fps) as writer:
if not frame_paths:
return
for fn in frame_paths:
writer.append_data(imageio.imread(fn))
if hold_frames > 0:
if hold_poster_path is None:
raise ValueError("hold_poster_path is required when hold_last_seconds > 0")
hold_img = imageio.imread(hold_poster_path)
for _ in range(hold_frames):
writer.append_data(hold_img)
def render_static(ts, steps, static_path: Path) -> None:
fig, ax = plt.subplots(figsize=(10, 6))
ax.axhline(0, linestyle="--", linewidth=1, alpha=0.3)
_draw_poster_staircase(ax, ts, steps)
ax.set_xlim(ts.index.min(), ts.index.max())
ax.set_ylim(ts.min() - 0.1, ts.max() + 0.16)
ax.set_xlabel("Year")
ax.set_ylabel("Temperature anomaly (°C)")
ax.set_title(chart_title(), fontsize=13, fontweight="bold")
ax.grid(True, which="major", axis="y", linestyle="--", linewidth=0.5, alpha=0.3)
ax.text(
0.99,
-0.1,
"Source: HadCRUT5 by @iamreddave",
ha="right",
va="top",
transform=ax.transAxes,
fontsize=9,
color="gray",
)
ax.text(
0.20,
-0.1,
"Anomalies relative to 1961-1990",
ha="right",
va="top",
transform=ax.transAxes,
fontsize=9,
color="gray",
)
static_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(static_path, dpi=150, bbox_inches="tight")
plt.close(fig)
def main() -> None:
parser = argparse.ArgumentParser(description="Build Staircase of Denial GIF from HadCRUT5.")
parser.add_argument(
"--out-dir",
type=Path,
default=Path(__file__).resolve().parent / "output",
help="Directory for GIF, PNG, and temporary frames",
)
parser.add_argument("--fps", type=float, default=7.0)
parser.add_argument(
"--hold-last-seconds",
type=float,
default=3.0,
metavar="SEC",
help=(
"Hold the full poster (same as staircase_static.png: all steps + yr labels) "
"for about this many seconds after the last year frame"
),
)
parser.add_argument("--keep-frames", action="store_true")
args = parser.parse_args()
out_dir = args.out_dir
frames_dir = out_dir / "frames"
if frames_dir.exists():
shutil.rmtree(frames_dir)
print("Fetching HadCRUT5 …")
ts = load_series()
steps, limits = compute_steps(ts)
print("Plateaus (>=3 yr between successive record highs since 1980):")
for s, e, y in steps:
print(f" {s} → {e} : {e - s} years at {y:.3f} °C")
print("Rendering static PNG …")
static_path = out_dir / "staircase_static.png"
render_static(ts, steps, static_path)
print(f" wrote {static_path}")
print("Rendering animation …")
frame_paths = render_animation_frames(ts, steps, limits, frames_dir)
hold_poster_path = frames_dir / "_poster_hold.png"
hold_frames = max(0, int(round(args.hold_last_seconds * args.fps)))
if hold_frames > 0:
print("Rendering GIF hold poster (matches static PNG layout) …")
render_gif_hold_poster(ts, steps, limits, hold_poster_path)
gif_path = out_dir / "staircase_hadcrut5.gif"
total_gif_frames = len(frame_paths) + hold_frames
print(
f"Building GIF ({len(frame_paths)} year frames + {hold_frames} poster hold "
f"≈ {args.hold_last_seconds:g}s → {total_gif_frames} total @ {args.fps} fps) …"
)
write_gif(
frame_paths,
gif_path,
fps=args.fps,
hold_last_seconds=args.hold_last_seconds,
hold_poster_path=hold_poster_path if hold_frames > 0 else None,
)
print(f" wrote {gif_path}")
if not args.keep_frames:
shutil.rmtree(frames_dir)
print("Removed temporary frames/")
if __name__ == "__main__":
main()
import numpy as np
import matplotlib.pyplot as plt
# Assume df is your DataFrame and ts = df['Anomaly'], indexed by Year
# 1) Identify every record-breaking year ≥ 1980
post80 = ts.where(ts.index >= 1980)
current = -np.inf
records = []
values = []
for yr, val in post80.items():
if np.isnan(val): continue
if val >= current:
current = val
records.append(yr)
values.append(val)
# 2) Build steps between successive records
all_steps = list(zip(records, records[1:], values))
# 3) Keep only multi‑year flats (>=3 yr)
steps = [(s, e, y) for s, e, y in all_steps if (e - s) >= 3]
# 4) Plot once, statically
fig, ax = plt.subplots(figsize=(10, 6))
# zero‑line highlight
ax.axhline(0, linestyle='--', linewidth=1, alpha=0.3)
# full anomaly series
# ax.plot(ts.index, ts.values, lw=1, color='lightgray')
ax.plot(ts.index, ts.values, lw=1, color='black')
# draw and label each plateau
for s, e, y in steps:
ax.hlines(y, s, e, linewidth=3, color='C0')
mid = s + (e - s) / 2
ax.text(
mid, y + 0.02,
f"{e - s}yr",
ha='center', va='bottom',
fontweight='bold', fontsize=10
)
# lock your limits
ax.set_xlim(ts.index.min(), ts.index.max())
ax.set_ylim(ts.min() - 0.1, ts.max() + 0.1)
# labels & title
ax.set_xlabel('Year')
ax.set_ylabel('Temperature anomaly (°C)')
ax.set_title(
"Staircase of Denial\n"
"“No Warming in Years”",
fontsize=14, fontweight='bold'
)
# light horizontal grid lines at major ticks
ax.grid(
True,
which='major',
axis='y',
linestyle='--',
linewidth=0.5,
alpha=0.3
)
# 6) source & notes
ax.text(0.99, -0.1,
"Source: HadCRUT5 by @iamreddave",
ha='right', va='top',
transform=ax.transAxes,
fontsize=9, color='gray')
ax.text(0.20, -0.1,
"Anomalies relative to 1961-1990",
ha='right', va='top',
transform=ax.transAxes,
fontsize=9, color='gray')
# save
output_path = '/content/staircase_static.png'
fig.savefig(output_path, dpi=150, bbox_inches='tight')
plt.show()
print("Saved static image to", output_path)
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
@cavedave
Copy link
Copy Markdown
Author

staircase_hadcrut5

@cavedave
Copy link
Copy Markdown
Author

staircase_static (3)

@cavedave
Copy link
Copy Markdown
Author

staircase_hadcrut5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment