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.

Everything Fixed Except the Data A report figure divided into components. The map panel, the legend with fixed classification breaks, the title pattern, the footnote block stating suppression and provisional rules, and the provenance line carrying the run identifier and data period are all fixed by the product template. Only the data layer inside the map panel changes weekly. A note records that a change to any fixed component is a product revision that requires review and a dated entry, while a change to the data slot is the normal weekly update. The weekly run fills one slot; everything else is the template Title pattern: condition, geography, period — fixed Data layer — the only weekly input extent and projection fixed around it Legend — fixed breaks under 10 10 to 25 25 to 50 50 and over suppressed Footnotes: suppression rule, provisional weeks, rate basis — fixed Provenance line printed on the figure: run id · data through · generated

Prerequisites

  • python 3.11
  • matplotlib 3.9.0, geopandas 0.14.4, pandas 2.2.2, numpy 1.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.

A Figure That Survives Being Forwarded A report page containing a map, surrounding text explaining the provisional weeks and suppression rule, and a methods paragraph. An arrow shows the map being cut out and pasted into a slide deck, where the surrounding text is lost. Because the provenance line and footnotes are part of the figure image rather than the page, the pasted map still states its run identifier, the date the data run through, which weeks are provisional and how suppressed areas are drawn. Figures get separated from reports; the context must travel inside them Weekly report page map + footnotes + provenance line surrounding text explaining provisional weeks and methods copied Somebody's slide deck map + footnotes + provenance line still says run, period, provisional weeks, suppression

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:

Low, Withheld and Not Reported Are Different Facts Three legend entries for a weekly report map. A low rate is drawn in the lightest class of the colour ramp. A suppressed area, whose value is withheld to protect privacy, is drawn with hatching. A jurisdiction whose feed did not arrive that week is drawn as an outline only, labelled not reported, and listed in the footnotes. A map that draws all three the same way tells readers something false about at least two of them. A blank area must say why it is blank low rate lightest colour class suppressed value withheld for privacy outline only not reported feed missing, in footnotes drawing all three identically misstates at least two of them

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.