Last active
May 19, 2026 07:49
-
-
Save cavedave/a11fa410a471b4fb50b656e76e3edbe0 to your computer and use it in GitHub Desktop.
staircase.ipynb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
Author
cavedave
commented
May 19, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment