Automating Static Map Exports for Weekly Reports
This guide, part of Publishing Surveillance Map Products, automates the map that appears in a weekly surveillance report — a product with a small audience, high influence, and a strong tendency to drift when it is made by hand.
Problem Context & Constraints
A weekly report map made by hand in a desktop tool changes in small ways every week. The classification breaks are recomputed from the new data; the colour ramp is picked from a menu; the extent is zoomed slightly differently; a footnote about provisional data is remembered one week and forgotten the next. Each change is minor. Together they make week-over-week comparison unreliable, and a reader comparing two reports is comparing two authoring sessions rather than two weeks of disease.
Automating the export fixes this by making every visual decision configuration rather than a choice. Three constraints shape it.
Every encoding decision is fixed per product. Breaks, ramp, extent, projection, legend wording and footnotes are set once, stored as configuration and changed only through a reviewed revision. The weekly run supplies data and nothing else.
The figure must carry its own provenance. A report page is screenshotted, forwarded and pasted into slide decks, and once separated from the report it loses its context. The run identifier, data period, provisional status and suppression note belong on the figure itself.
The output must be accessible. A choropleth that encodes meaning only in hue excludes readers with colour vision deficiency and fails on a greyscale printout. The ramp must be distinguishable in luminance, and the values must be available in a table beside the map.
Prerequisites
python3.11matplotlib3.9.0,geopandas0.14.4,pandas2.2.2,numpy1.26.4- Suppressed aggregates from the published store for the reporting period
- A product configuration file: breaks, ramp, extent, projection, title pattern, footnotes
- The run provenance record, for the figure’s provenance line
Step-by-Step Solution
Load the product configuration, render the map with fixed encodings, draw suppressed areas distinctly, print the provenance on the figure, and write both the image and an accessible table.
# Weekly report map from a fixed product template.
# Pinned: matplotlib==3.9.0, geopandas==0.14.4, pandas==2.2.2, numpy==1.26.4
import json
import logging
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.patches import Patch
import numpy as np
import pandas as pd
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("publish.static")
def load_product(path: str) -> dict:
with open(path) as fh:
cfg = json.load(fh)
required = {"product_id", "revision", "breaks", "colors", "extent", "crs",
"title_pattern", "footnotes", "suppressed_label"}
missing = required - set(cfg)
if missing:
raise KeyError(f"product configuration incomplete: {sorted(missing)}")
if len(cfg["colors"]) != len(cfg["breaks"]) + 1:
raise ValueError("need exactly one colour per class")
return cfg
def render_weekly_map(layer: gpd.GeoDataFrame, cfg: dict, provenance: dict,
out_png: str, out_csv: str, value: str = "rate") -> None:
"""Render with fixed encodings. The weekly run supplies data only."""
layer = layer.to_crs(cfg["crs"])
bounds = [-np.inf] + cfg["breaks"] + [np.inf]
cmap = ListedColormap(cfg["colors"])
norm = BoundaryNorm(bounds, cmap.N)
fig, ax = plt.subplots(figsize=(8.5, 6.2), dpi=200)
shown = layer[~layer["suppressed"]]
hidden = layer[layer["suppressed"]]
shown.plot(column=value, cmap=cmap, norm=norm, ax=ax, edgecolor="#555555",
linewidth=0.3)
# Suppressed: hatched, never coloured as the lowest class.
hidden.plot(ax=ax, facecolor="none", edgecolor="#555555", hatch="////", linewidth=0.3)
minx, miny, maxx, maxy = cfg["extent"] # fixed, not data-driven
ax.set_xlim(minx, maxx); ax.set_ylim(miny, maxy); ax.set_axis_off()
labels = _class_labels(cfg["breaks"])
handles = [Patch(facecolor=c, edgecolor="#555555", label=l)
for c, l in zip(cfg["colors"], labels)]
handles.append(Patch(facecolor="white", edgecolor="#555555", hatch="////",
label=cfg["suppressed_label"]))
ax.legend(handles=handles, loc="lower left", frameon=False, fontsize=7,
title=cfg.get("legend_title", "Rate per 100,000"), title_fontsize=7)
ax.set_title(cfg["title_pattern"].format(**provenance), fontsize=10, loc="left")
foot = " · ".join(cfg["footnotes"])
prov = (f"Run {provenance['run_id']} · data through {provenance['data_through']} · "
f"provisional from {provenance['provisional_from']} · "
f"product {cfg['product_id']} rev {cfg['revision']}")
fig.text(0.01, 0.035, foot, fontsize=6.5, ha="left")
fig.text(0.01, 0.012, prov, fontsize=6, ha="left", color="#444444")
fig.savefig(out_png, bbox_inches="tight")
plt.close(fig)
# Accessible companion: the same values as a table, suppressed cells marked.
table = layer[["geoid", "name", value, "suppressed", "provisional"]].copy()
table[value] = table[value].where(~table["suppressed"]).round(1)
table.to_csv(out_csv, index=False)
log.info("rendered %s and %s (areas=%d suppressed=%d product=%s rev=%s)",
out_png, out_csv, len(layer), int(layer["suppressed"].sum()),
cfg["product_id"], cfg["revision"])
def _class_labels(breaks: list[float]) -> list[str]:
edges = [None] + breaks + [None]
out = []
for lo, hi in zip(edges[:-1], edges[1:]):
out.append(f"under {hi:g}" if lo is None else
f"{lo:g} and over" if hi is None else f"{lo:g} to {hi:g}")
return out
The fixed extent is easy to overlook and matters more than it seems. A data-driven extent zooms to wherever the cases are, so a week with a rural outbreak shows a different frame from a week with an urban one, and a reader’s sense of scale changes with it. The same frame every week makes the map comparable as an image, which is how most readers compare it.
The product revision printed on the figure is what makes a legend change visible. When the breaks are revised — annually, say — the revision number changes, and a reader holding two printouts with different revision numbers knows the colours are not directly comparable.
Validation & Edge Cases
Breaks that no longer fit the data. If most areas fall in one class for several weeks, the fixed breaks have stopped discriminating. That is a trigger for a reviewed revision, not for recomputing breaks in the weekly run.
Suppressed areas drawn as the lowest class. A null value passed to a colour map often renders as the first colour. Draw suppressed areas as a separate layer with a distinct pattern, and include that pattern in the legend.
Small multiples for trend. Readers comparing weeks often want several on one page. Rendering small multiples from the same template guarantees identical encodings across panels, which is the whole point; building them by placing separately rendered images side by side reintroduces the per-image differences the template was meant to remove. Render the panel set as one figure with a single shared legend.
A week with no data for a jurisdiction. A missing feed should not render as the lowest class or as suppressed, because it is neither. Give it a third treatment — outline only, labelled “not reported” — and list it in the footnotes, so a reader does not mistake a data gap for a low rate or for a protective suppression.
Export resolution for print. A figure rendered at screen resolution looks acceptable in a browser and blurs in a printed briefing pack. Render at print resolution once and derive smaller versions from it, rather than rendering separately for each medium and letting them diverge.
Colour ramps that fail in greyscale. Check the ramp’s luminance steps; a hue-only ramp prints as uniform grey. Sequential ramps with monotonic lightness are the safe default.
Areas added or removed between vintages. A boundary vintage change alters the map’s shapes, which readers notice. Record the vintage on the figure when it changes, and note it in the report text for that week.
Rendering differences between library versions. A plotting library upgrade can shift label placement or line weights. Include the weekly map in the regression suite with an image comparison at a tolerance, so an upgrade does not quietly change the product’s appearance.
INFO publish.static rendered /publish/runs/20260918-4f2a/weekly_map.png and /publish/runs/20260918-4f2a/weekly_map.csv (areas=58 suppressed=6 product=ILI-COUNTY-WEEKLY rev=3)
The three missing-value treatments recommended in that list keep a data gap, a protective suppression and a low rate from reading the same way:
Compliance Notes
Store the product configuration under version control with its revision history: each revision’s breaks, ramp, extent and footnotes, the date it took effect and who approved it. Record, for every weekly output, the product revision used and the run identifier.
The companion table is a published artifact as much as the map, and it is the one most likely to be reused — copied into a spreadsheet, merged with another dataset. It must come from the same suppressed store and carry the same suppression markers, and the review of the release as a whole should include it. A report whose map is suppressed and whose accompanying table is not has published the numbers the map was designed to withhold.
Related Topics
- Publishing Surveillance Map Products — the parent guide and why fixed breaks make weeks comparable.
- Documenting a Release With ISO 19115 Metadata — the machine-readable counterpart to the figure’s provenance line.
- Mapping Exceedance Probabilities Instead of Raw Rates — an alternative quantity for the weekly map when small-area rates are unstable.