Debugging Inconsistent Extents Across Atlas Pages

When sheet 1 is correct and every later sheet drifts, the fault is almost never in the extent computation — it is mutable layout or style state carried forward from one loop iteration to the next, and the fix is to make each sheet independent rather than to correct the arithmetic.

Core Diagnostic Workflow

The signature is the diagnosis. A pipeline that computes extents wrongly gets sheet 1 wrong too; a pipeline that leaks state gets sheet 1 right and everything after it progressively wrong. So the first question is not “is the extent maths correct” but “is the first sheet correct”.

From there:

  1. Verify the grid before the loop. Dump the computed extents and check a few against the coverage. If they are wrong here, the fault is in grid generation and the loop is innocent.
  2. Read back what each sheet drew. Most renderers can report the extent actually rendered. Diff that against the grid cell the sheet was given.
  3. Find the first divergence. In a serial run the first sheet whose rendered extent differs from its cell identifies the iteration whose predecessor leaked.
  4. Look for mutation rather than computation. The culprit is an object modified inside the loop — a layout whose map item extent was set, a style whose layer visibility was toggled, a renderer whose scale was pinned.
  5. Snapshot and restore, then pass by value. Restoring state each iteration stops the bleeding; passing an immutable description removes the mechanism.
The accumulating signature of leaked state Extent error in metres plotted per sheet from 1 to 20. With leaked state, sheet 1 has zero error and the error grows monotonically to 480 metres by sheet 20. With state restored at each iteration, every sheet has zero error. A note records that the monotone growth from a correct first sheet is the signature that identifies the fault class. 0 m 250 500 extent error sheet number 1 6 13 20 sheet 1 correct state leaked state restored Monotone growth from a correct first sheet identifies the fault class before any code is read. An arithmetic error would have made sheet 1 wrong too.
The shape of the error curve is more informative than its magnitude. Anything that starts at zero and grows is carrying something forward.

Production-Ready Python Implementation

import copy
from dataclasses import dataclass


@dataclass(frozen=True)
class SheetSpec:
    """Everything a sheet renderer is allowed to know. Immutable by construction."""
    page: int
    extent: tuple           # (minx, miny, maxx, maxy)
    scale_denominator: float
    visible_layers: tuple   # a tuple, so it cannot be appended to


def render_series(specs, layout, style, render_one) -> list:
    """Render every sheet against a pristine copy of the shared state."""
    base_layout = copy.deepcopy(layout)
    base_style = copy.deepcopy(style)

    results = []
    for spec in specs:
        # Each iteration starts from the same state the first one saw.
        sheet_layout = copy.deepcopy(base_layout)
        sheet_style = copy.deepcopy(base_style)
        results.append(render_one(spec, sheet_layout, sheet_style))
    return results


def audit_extents(specs, rendered_extents, tolerance: float = 0.5) -> list:
    """Report sheets whose rendered extent differs from the one they were given."""
    problems = []
    for spec, actual in zip(specs, rendered_extents):
        deltas = [abs(a - b) for a, b in zip(spec.extent, actual)]
        if max(deltas) > tolerance:
            problems.append({"page": spec.page, "expected": spec.extent,
                             "actual": actual, "max_delta": max(deltas)})
    return problems

The deep copy per iteration is the pragmatic fix and it costs almost nothing next to a render. The structural fix is SheetSpec being frozen with tuple fields: a renderer handed that object cannot accumulate anything into it, so there is nothing for the next iteration to inherit. Where the renderer is a third-party object with internal state — a QGIS layout, say — the copy is the only lever available, and audit_extents is what proves it worked.

Failure Modes, Root Causes, and Fixes

Extents drift monotonically from sheet 2. Layout state mutated in the loop. Deep-copy per iteration, then look for the specific setter.

One sheet in the middle is wrong and the rest are fine. Not a leak — that sheet’s grid cell is wrong, or its data differs. Check the cell against the coverage.

Extents are exact and the maps differ. Something other than extent leaked: layer visibility, a pinned scale, legend filter state, or a renderer cache keyed on something that does not include the sheet. Extent comparison alone is not a sufficient test, which is why the audit should include a content hash as well.

The drift appears only in the parallel run. Shared mutable state across workers, or a cache written by one worker and read by another. Reproduce serially before debugging; the serial signature is far easier to read.

Sheet 1 is also wrong. Then it is not a leak. Go back to the grid generation and the scale reconciliation, as described in Generating Atlas Page Grids with Guaranteed Overlap.

What leaks, and which leaks an extent check can see Four leaked-state categories. Map item extent and pinned scale are visible to an extent comparison. Layer visibility toggled by a script and legend filter state are not, because the extent is correct while the content differs. A note records that the audit therefore needs a content hash as well as an extent comparison. what leaked extent check sees it? map item extent set in the loop yes scale pinned by hand on one sheet yes layer visibility toggled by a script no legend filter state no The dashed rows produce sheets with provably correct extents and visibly different content, so the audit needs a content hash alongside the extent comparison.
Half the leaks are invisible to the most obvious check. Adding a per-sheet content hash to the audit costs one line and doubles what it catches.

Integration and Next Steps

The audit belongs in the run report described in Atlas and Map Series Automation, so a drift is caught by the pipeline rather than by a reader turning pages. Passing an immutable SheetSpec also makes the render function trivially parallelisable, which connects directly to the fan-out described in Batch Queue Orchestration — a function that cannot mutate shared state is safe to run in as many workers as memory allows.

Immutability is what makes the render loop parallelisable Two arrangements. In the first, four sheet renderers share one mutable layout object, so each can affect the others and the loop must run serially. In the second, each renderer receives its own frozen spec and a private copy of the layout, so the four are independent and can run in any order or simultaneously. shared mutable layout layout (mutable) serial only — each affects the next per-sheet frozen spec spec 1 spec 2 spec 3 spec 4 any order, any number of workers The bug fix and the throughput improvement are the same change, which is unusually convenient.
Removing shared mutable state fixes the correctness problem and unlocks the parallelism at the same time. That is worth more than the deep-copy workaround it replaces.

Frequently Asked Questions

Why is sheet 1 always correct?

Because it runs against clean state. Every leaked-state bug in a loop shares that signature: the first iteration sees the initial conditions and each subsequent one sees whatever its predecessor left behind. The signature is the diagnosis — a correct first sheet followed by monotone drift means the extent computation is fine and something is being mutated inside the loop. An arithmetic error would have made sheet 1 wrong as well.

The extents match the grid but the maps still differ. What else leaks?

Layer visibility toggled by a script, a scale pinned by hand on one page, legend filter state, and any renderer-level cache whose key does not include the sheet. All of these produce pages whose extents are provably correct and whose content is not. That is why the audit should compare a per-sheet content hash in addition to the extent — the extra check costs almost nothing and covers the half of the fault space the extent comparison cannot see.

Does rendering in parallel make this better or worse?

Better in substance, worse for diagnosis. Separate worker processes cannot leak layout objects to each other, so the fault class largely disappears. But with sheets completing out of order, the “first divergent sheet” signature is gone and the drift shows up as a scattered set of wrong pages that is much harder to bisect. Reproduce the problem in a serial run before trying to understand it.

Should the sheet renderer take the layout by value?

Yes — that is the structural fix rather than a workaround. A renderer handed an immutable description cannot affect the next iteration, so the snapshot-and-restore step stops being necessary at all. Restoring state each time treats the symptom and leaves the mechanism intact, which means the next person to add a setter inside the loop reintroduces the bug.


Back to Atlas and Map Series Automation