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:
- 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.
- Read back what each sheet drew. Most renderers can report the extent actually rendered. Diff that against the grid cell the sheet was given.
- Find the first divergence. In a serial run the first sheet whose rendered extent differs from its cell identifies the iteration whose predecessor leaked.
- 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.
- Snapshot and restore, then pass by value. Restoring state each iteration stops the bleeding; passing an immutable description removes the mechanism.
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.
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.
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.
Related
- Generating Atlas Page Grids with Guaranteed Overlap — where the extents each sheet should have are computed.
- Building a Map Series Index Sheet with Page Numbers — the other artefact that depends on the grid being authoritative.
Back to Atlas and Map Series Automation