cassionData Analysis

Lesson 5 of 8

Unit · One template, many outputs

One template, twelve districts, no copy anywhere

Twelve district reports produced by copy-paste are twelve documents that will disagree by March. One parameterised template rendered twelve times is one document that cannot, and the mechanism is a single line in the frontmatter.

PythonR135 minOECD DAC evaluation criteriaUNICEF indicator definitionsCore Humanitarian Standard (CHS)

The report that becomes twelve reports

A cluster wants a report per district. The obvious approach is to write one, copy it eleven times and change the filter at the top of each.

By March those twelve files disagree. Someone fixes the commune-name normalisation in one of them. Someone adds a caveat to three. The indicator definition changes and eight get updated. Nothing in any build can detect that they have drifted, because they are twelve unrelated documents.

A parameterised template is one document. Fixing it fixes twelve reports, and there is nowhere for a difference to hide.

Quarto, which this platform already uses

---
title: "Nutrition surveillance: `r params$district`"
params:
  district: "Artibonite"
  as_of: "2025-03-31"
format: pdf
---
quarto render report.qmd -P district:Nord-Ouest -P as_of:2025-03-31

The document declares its parameters and the renderer supplies them. The body refers to params$district — in R — or to district from the injected parameters cell in Python, and nothing else in the file mentions a specific district.

# | tags: [parameters]
district = "Artibonite"
as_of = "2025-03-31"
params$district

The default value in the frontmatter is what makes the template previewable. You open it, it renders for Artibonite, and you are editing something you can see.

Rendering all twelve

import subprocess, pathlib

DISTRICTS = ["Artibonite", "Centre", "Grande-Anse", "Nippes", "Nord",
             "Nord-Est", "Nord-Ouest", "Ouest", "Sud", "Sud-Est"]

for district in sorted(DISTRICTS):
    slug = district.lower().replace(" ", "-")
    subprocess.run([
        "quarto", "render", "report.qmd",
        "-P", f"district:{district}",
        "-P", f"as_of:{AS_OF}",
        "--output", f"outputs/reports/{slug}.pdf",
    ], check=True)
for (d in sort(districts)) {
  quarto::quarto_render(
    "report.qmd",
    execute_params = list(district = d, as_of = as_of),
    output_file = paste0(tolower(gsub(" ", "-", d)), ".pdf")
  )
}

check=True is the important argument. Without it a failed render is a missing file that the loop steps over silently, and you discover it when someone asks for the Nippes report.

Sort the district list, for the reason the last lesson gave: the loop’s output order should not depend on how the list was assembled.

Writing a template that survives its own parameters

Four things a copied report gets away with and a template does not.

No hard-coded numbers in the prose. Every figure in the text comes from the data.

Coverage in `r params$district` is
`r scales::percent(coverage, accuracy = 0.1)`, against a national figure of
`r scales::percent(national, accuracy = 0.1)`.

Handle the district with no data. One of the twelve will have an empty table, and a template that assumes rows produces a stack trace or, worse, a page of NaN.

if summary.empty:
    print(f"No screening was conducted in {district} during this period.")
else:
    ...
if (nrow(summary) == 0) cat("No screening was conducted in this period.")

Handle the singular. “1 communes” in eleven reports is the sign of a template nobody proofread.

Handle the small denominator. A district with 40 screenings gets an interval three times wider than one with 400, and the template has to say so rather than printing both to one decimal place as though they were comparable.

What belongs in the template and what belongs in the pipeline

In the template In the pipeline
Layout, prose, the shape of the argument Reading raw data
A summary table computed from prepared data Cleaning and normalisation
Figures, drawn from prepared data Any transformation more than a line or two
Caveats, thresholds and definitions Anything two reports would share

The template should read a prepared file, not the raw export. If twelve renders each redo the cleaning, the cleaning runs twelve times, takes twelve times as long, and — worse — can be edited in the template for one district only.

# run.py
clean()                       # once
build_figures()               # once
for district in DISTRICTS:    # twelve times, from prepared data
    render(district)
# run.R, same shape

The parameter you should always have

params:
  district: "Artibonite"
  as_of: "2025-03-31"
  data_version: "v1"

A data version parameter makes the report say which file it read, which is the provenance question a reader asks six months later. Print it in the colophon.

And an as_of parameter rather than the clock, for the reason lesson 4 gave: a report that says “generated 14 March” changes every time it is rebuilt, and a report that says “data as of 31 March” does not.

Where this platform does the same thing

Every lesson on this site produces a slide deck in two languages and three formats — a Beamer PDF, a PowerPoint file and the LaTeX source, six files per lesson — and none of them was authored.

slide-plan.mjs plans the deck once from the lesson body, and the Beamer PDF, the PowerPoint file and the in-browser slide reader are three renderings of that one plan.

The reasoning is the same as the twelve districts’, at a different scale. A slides: array in the frontmatter would produce better slides in principle and worse ones in practice: it means every new lesson needs a second authoring pass in two languages, and that is the pass that gets skipped.

The consequence for authors is real and worth naming. Because the deck is derived, lessons are written with lists, bold lead-ins, tables and callouts — because that is what a deck is made of. A parameterised system shapes its inputs, and pretending otherwise produces templates that fight their own content.

Report it whole

District reports

  Twelve district reports are rendered from one template, report.qmd, by
  run.py. No district-specific text exists outside the data.

  Parameters: district, as_of (2025-03-31), data_version (v1). Each report
  prints all three in its colophon.

  Cleaning and figure generation run once, before rendering; the template
  reads prepared data and performs no transformation.

  Districts with no screening in the period render a stated "no data"
  section rather than an empty table.

  Rendering is verified by `--check`: a failed render stops the run rather
  than leaving a missing file.

The last line is what turns twelve rendered files into twelve reports you can send. A loop that swallows failures produces eleven reports and a question nobody asks until the wrong person notices.

What comes next

A pipeline that renders twelve reports from broken upstream data renders twelve broken reports. The next lesson makes it stop instead — loudly, at the point of failure, rather than producing a plausible number nobody can check.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.