"""Demo charts for 'Scars from Space: Ukraine's Fire Trend Detective'. Usage: python3 -I charts.py Palette: dataviz reference instance (light). MODIS and VIIRS are never summed. """ import os import sys import geopandas as gpd import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 import numpy as np import pandas as pd from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm from matplotlib.lines import Line2D from matplotlib.ticker import NullFormatter SURF, INK, INK2, MUTED, GRID, AXIS = "#fcfcfb", "#0b0b0b", "#52514e", "#898781", "#e1e0d9", "#c3c2b7" S1, S2 = "#2a78d6", "#eb6834" # categorical slots 1-2 NEUTRAL = "#f0efec" DIV = LinearSegmentedColormap.from_list( "div", ["#184f95", "#3987e5", "#86b6ef", NEUTRAL, "#f0a3a2", "#e34948", "#a32a2a"]) SRC = "Data: NASA FIRMS (MODIS C6.1, VIIRS S-NPP 375 m), vegetation-fire detections; weather: MERRA-2 via NASA POWER." plt.rcParams.update({ "font.family": "DejaVu Sans", "font.size": 11, "text.color": INK, "axes.labelcolor": INK2, "axes.edgecolor": AXIS, "xtick.color": MUTED, "ytick.color": MUTED, "axes.facecolor": SURF, "figure.facecolor": SURF, "savefig.facecolor": SURF, "axes.spines.top": False, "axes.spines.right": False, "axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.8, }) def footer(fig, text=SRC): fig.text(0.01, 0.01, text, fontsize=8.5, color=MUTED, ha="left", va="bottom") def national(work, out): nat = pd.read_csv(os.path.join(work, "national.csv"), index_col=0) nat = nat[nat.index <= 2025] fig, ax = plt.subplots(figsize=(11, 5.6)) for col, c in (("MODIS", S1), ("VIIRS", S2)): s = nat[col].dropna() s = s[s > 0] ax.plot(s.index, s.values / 1000, color=c, lw=2, marker="o", ms=5, label=col) ax.text(s.index[-1] + 0.25, s.values[-1] / 1000, col, color=INK2, fontsize=10, va="center") ax.set_ylim(0, 175) for yr, lab in ((2014, "2014: war in Donbas, Crimea occupied"), (2022, "Feb 2022: full-scale invasion")): ax.axvline(yr, color=MUTED, lw=1, ls=(0, (3, 3))) ax.text(yr + 0.15, 171, lab, fontsize=9, color=INK2, va="top") ax.set_ylabel("Fire detections per year (thousands)") ax.set_xlim(2000.5, 2026.2) ax.legend(frameon=False, loc="upper left", bbox_to_anchor=(0.0, 0.88)) fig.suptitle("Clue #1: across all of Ukraine, 2022 does not look like a spike", x=0.01, ha="left", fontsize=15, color=INK, fontweight="bold") ax.set_title("Each satellite counts on its own scale, so the two lines are never added together", loc="left", fontsize=10.5, color=INK2) footer(fig) fig.tight_layout(rect=(0, 0.04, 1, 1)) fig.savefig(os.path.join(out, "01_national_fires.png"), dpi=160) plt.close(fig) def dotplot(work, out): r = pd.read_csv(os.path.join(work, "results.csv")) v = r[r.sensor == "VIIRS"].set_index("region") m = r[r.sensor == "MODIS"].set_index("region") v = v.sort_values("ratio") fig, ax = plt.subplots(figsize=(11, 8.2)) y = np.arange(len(v)) colors = [S2 if g == "frontline_2022" else S1 for g in v.group] for i, reg in enumerate(v.index): if reg in m.index: ax.plot([m.loc[reg, "ratio"], v.loc[reg, "ratio"]], [i, i], color=GRID, lw=2, zorder=1) ax.scatter(m.loc[reg, "ratio"], i, s=55, facecolor=SURF, edgecolor=colors[i], lw=1.8, zorder=2) ax.scatter(v.ratio, y, s=90, c=colors, edgecolor=SURF, lw=2, zorder=3) for i, reg in enumerate(v.index): sig = " *" if v.loc[reg, "q"] < 0.05 else "" ax.text(0.17, i, reg + sig, ha="right", va="center", fontsize=10, color=INK) ax.set_xscale("log") ax.set_xlim(0.18, 5) ax.set_xticks([0.25, 0.5, 1, 2, 4]) ax.set_xticklabels(["¼×", "½×", "1× (as expected)", "2×", "4×"]) ax.xaxis.set_minor_formatter(NullFormatter()) ax.set_yticks([]) ax.spines["left"].set_visible(False) ax.axvline(1, color=AXIS, lw=1.5, zorder=0) ax.set_xlabel("Fires observed (Mar 2022–2025) ÷ fires expected from weather and pre-war history, log scale") handles = [Line2D([], [], marker="o", ls="", ms=9, color=S2, label="Frontline in 2022"), Line2D([], [], marker="o", ls="", ms=9, color=S1, label="Rear regions"), Line2D([], [], marker="o", ls="", ms=8, mfc=SURF, mec=INK2, label="MODIS (second witness)"), Line2D([], [], marker="o", ls="", ms=9, color=INK2, label="VIIRS (main witness)")] ax.legend(handles=handles, frameon=False, loc="lower right", fontsize=10) fig.suptitle("Clue #2: same country, opposite stories", x=0.01, ha="left", fontsize=15, fontweight="bold", color=INK) ax.set_title("Frontline regions burned 2–3× more than the rear, after accounting for weather.\n" "* = significant on its own (VIIRS, false-discovery-rate q < 0.05)", loc="left", fontsize=10.5, color=INK2) footer(fig, "Data: NASA FIRMS (MODIS C6.1, VIIRS S-NPP 375 m); weather: MERRA-2 via NASA POWER.\n" "Donetsk, Luhansk, Crimea not shown: no clean VIIRS pre-war baseline (war since 2014).") fig.tight_layout(rect=(0.12, 0.05, 1, 1)) fig.savefig(os.path.join(out, "02_frontline_vs_rear.png"), dpi=160) plt.close(fig) def choropleth(work, out): reg = gpd.read_file(os.path.join(work, "regions.geojson")) r = pd.read_csv(os.path.join(work, "results.csv")) v = r[r.sensor == "VIIRS"][["region", "ratio", "q"]] g = reg.merge(v, on="region", how="left") g["l2"] = np.log2(g["ratio"]) fig, ax = plt.subplots(figsize=(11, 7.4)) norm = TwoSlopeNorm(vmin=-2, vcenter=0, vmax=2) has = g[g.l2.notna()] has.plot(ax=ax, column="l2", cmap=DIV, norm=norm, edgecolor=SURF, linewidth=1.6) no = g[g.l2.isna()] no.plot(ax=ax, color="#e1e0d9", edgecolor=SURF, linewidth=1.6, hatch="////") for _, row in g.iterrows(): p = row.geometry.representative_point() name = {"Ivano-Frankivsk": "Ivano-\nFrankivsk", "Khmelnytskyi": "Khmel-\nnytskyi", "Chernivtsi": "Cherniv-\ntsi", "Zakarpattia": "Zakar-\npattia"}.get(row.region, row.region) lab = name if pd.notna(row.ratio) else name + "\n(no VIIRS baseline)" val = "" if pd.isna(row.ratio) else f"\n{row.ratio:.1f}×" dark = pd.notna(row.l2) and abs(row.l2) > 1.1 ax.text(p.x, p.y, lab + val, ha="center", va="center", fontsize=6.9, color="#ffffff" if dark else INK) ax.set_axis_off() sm = plt.cm.ScalarMappable(cmap=DIV, norm=norm) cb = fig.colorbar(sm, ax=ax, orientation="horizontal", fraction=0.04, pad=0.02, aspect=40) cb.set_ticks([-2, -1, 0, 1, 2]) cb.set_ticklabels(["¼× fewer", "½×", "as expected", "2×", "4× more"]) cb.outline.set_visible(False) cb.ax.tick_params(colors=INK2, labelsize=9) fig.suptitle("Where: fires vs. weather-based expectation, Mar 2022–2025 (VIIRS)", x=0.01, ha="left", fontsize=15, fontweight="bold", color=INK) footer(fig, SRC + " Region totals only; no point locations shown.") fig.tight_layout(rect=(0, 0.04, 1, 0.96)) fig.savefig(os.path.join(out, "03_map_ratio.png"), dpi=160) plt.close(fig) def case(work, out, region="Kherson"): a = pd.read_csv(os.path.join(work, "annual.csv")) d = a[(a.region == region) & (a.sensor == "VIIRS")].sort_values("year") fig, ax = plt.subplots(figsize=(11, 5.4)) ax.plot(d.year, d.exp / 1000, color=MUTED, lw=2, ls=(0, (4, 3)), label="Expected (weather + history)") ax.plot(d.year, d.obs / 1000, color=S2, lw=2, marker="o", ms=6, label="Observed (VIIRS)") ax.axvspan(2021.5, d.year.max() + 0.5, color=NEUTRAL, zorder=0) ax.text(2021.65, ax.get_ylim()[0] + 0.1, "after Feb 2022", fontsize=9.5, color=INK2, va="bottom") ax.set_ylabel("Fire detections per year (thousands)") ax.legend(frameon=False, loc="upper left") fig.suptitle(f"Case file: {region} region", x=0.01, ha="left", fontsize=15, fontweight="bold", color=INK) ax.set_title("Before 2022, observed and expected move in the same range (each year predicted without " "seeing it).\nAfter 2022, observed stays above expected every year.", loc="left", fontsize=10.5, color=INK2) footer(fig, SRC + " 2022 counts Mar–Dec.") fig.tight_layout(rect=(0, 0.04, 1, 1)) fig.savefig(os.path.join(out, f"04_case_{region.lower()}.png"), dpi=160) plt.close(fig) if __name__ == "__main__": work, out = sys.argv[1], sys.argv[2] os.makedirs(out, exist_ok=True) national(work, out) dotplot(work, out) choropleth(work, out) case(work, out, "Kherson") print("ok")