Audit-Ready Report Generation for Zoning Compliance

A compliance verdict is only as defensible as the document that carries it into a public hearing, a permit file, or a legal appeal. This module sits inside Compliance Reporting & Audit Trail Generation and covers the final transformation in the pipeline: turning per-parcel pass/fail results, measured spatial metrics, and versioned rule references into portable artifacts that a reviewer can open, trust, and reproduce years later. The objective is not prettier output — it is evidentiary output, where every number on the page can be traced back to a specific geometry, coordinate reference system, and rule revision.

Rendering a report without moving decisions into the templateVerdicts are enriched into a rendering context where every phrase and rounding is decided, then handed to templates that only lay out.Verdict recordoutcome, measurement, threshold, margin, sourcesBuild the rendering contextformatting, phrasing and rounding decided hereTemplate lays it outordering, grouping, wording — no logicSame context, every formatthe PDF and the dashboard cannot disagree
If a template decides anything, that decision has escaped review, testing and the rule set.

Prerequisites

Report generation is a downstream stage, so it assumes a validated result set already exists. Before wiring in the renderers described below, confirm you have the following in place:

  • A results GeoDataFrame keyed by a stable parcel identifier (apn or parcel_id), carrying at minimum a status column and the measured metric that produced it.
  • The projected CRS used during evaluation, stored as an EPSG code alongside the results — never inferred at render time. This module’s examples assume EPSG:6539 (NAD83(2011) New York Long Island, meters).
  • A versioned rule reference for every flagged row: a rule_id plus a content hash or semantic version so the report can name the exact ordinance revision applied.
  • geopandas 1.0+, shapely 2.0+, and pyproj 3.4+ installed, plus a renderer per format (reportlab or weasyprint for PDF, folium for HTML).
  • Write access to an append-only or checksum-verified archive location for the finished bundle.

If your upstream stage does not yet emit CRS and rule provenance on each row, resolve that first — the validation log design module covers how to capture those fields at evaluation time so this stage can simply read them.

Core Workflow

The report generator is deliberately dumb about compliance logic. It never recomputes a verdict; it only serializes what evaluation already decided. Keeping it side-effect-free is what makes the output reproducible.

  1. Collect the frozen result set. Load the evaluation output exactly as written, without re-running spatial predicates. Re-deriving values at render time is the single most common source of report drift.
  2. Attach provenance columns. Merge rule versions, the CRS code, and a run timestamp onto every row so each artifact is self-describing.
  3. Render one artifact per audience. Planners want a signed-ready PDF; downstream GIS wants machine-readable geometry; reviewers want an interactive map. The same frozen frame feeds all three.
  4. Validate and checksum. Re-open each artifact, assert row counts and totals match the source frame, then hash the bytes.
  5. Archive the bundle. Write artifacts plus a manifest to immutable storage.
import geopandas as gpd
import pandas as pd
import hashlib
from datetime import datetime, timezone

EVAL_CRS = "EPSG:6539"  # NAD83(2011) NY Long Island (meters) used at evaluation time

def assemble_report_frame(results: gpd.GeoDataFrame,
                          rule_versions: pd.DataFrame) -> gpd.GeoDataFrame:
    # Never recompute status here; only enrich the frozen result set.
    frame = results.copy()
    if frame.crs is None or frame.crs.to_epsg() != 6539:
        raise ValueError("Results must arrive in the evaluation CRS, not be reprojected here")

    # Attach the exact rule revision applied to each parcel for traceability.
    frame = frame.merge(rule_versions, on="rule_id", how="left", validate="many_to_one")
    frame["crs_epsg"] = frame.crs.to_epsg()
    frame["report_generated_utc"] = datetime.now(timezone.utc).isoformat()
    return frame

def checksum_bytes(path: str) -> str:
    # SHA-256 of the rendered file goes into the manifest for tamper detection.
    with open(path, "rb") as fh:
        return hashlib.sha256(fh.read()).hexdigest()

Each output format then reads this enriched frame. The PDF compliance report guide shows how to lay the frame out as per-parcel tables with map thumbnails; the GeoJSON and CityGML export guide serializes it for downstream permitting systems; and the interactive HTML dashboard guide renders violation layers a reviewer can pan and inspect.

Implementation Patterns

Prefer a single render pass over the whole frame to per-row loops. Format serializers in the geospatial stack are vectorized, and a report of 40,000 parcels should not iterate 40,000 times in Python. Where a format genuinely needs per-parcel assets — a map thumbnail per row, for example — precompute them in a batch and reference them by parcel ID rather than generating them inline during layout.

Geometry destined for a human-readable artifact usually needs projecting back to a display or storage CRS, but the metric CRS remains the source of truth for every measured number. Compute areas and distances in EPSG:6539, write those scalars into attribute columns, and only then reproject a copy of the geometry for cartographic display:

def prepare_display_layer(frame: gpd.GeoDataFrame,
                          display_crs: str = "EPSG:4326") -> gpd.GeoDataFrame:
    # Metric measurements were computed upstream in EPSG:6539 and stored as columns.
    # Reproject a COPY only for display; measured values never change.
    display = frame.copy()
    display["area_sqm"] = display.geometry.area  # still in metric CRS
    display = display.to_crs(display_crs)         # geometry only, for maps
    return display

Separate the value you measure from the geometry you draw, and a reprojection can never silently corrupt a setback distance.

Edge Cases & Geometry Repair

Reports fail loudly on bad geometry more often than the evaluation stage does, because serializers such as the GeoJSON and GML drivers reject invalid rings that spatial predicates tolerated. Repair before rendering, and quarantine anything that cannot be fixed rather than dropping it silently — a missing parcel in a compliance report is itself a defect.

from shapely import make_valid

def sanitize_for_output(frame: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
    frame = frame.copy()
    null_mask = frame.geometry.is_empty | frame.geometry.isna()
    quarantine = frame[null_mask].copy()          # keep, do not discard

    valid = frame[~null_mask].copy()
    invalid = ~valid.geometry.is_valid
    valid.loc[invalid, "geometry"] = valid.loc[invalid, "geometry"].apply(make_valid)

    # make_valid can emit GeometryCollections; keep only polygonal parts for area reporting.
    valid["geometry"] = valid.geometry.buffer(0)
    return valid, quarantine

Watch for slivers produced by earlier overlay steps, self-intersections in digitized legacy parcels, and mixed geometry types after make_valid returns a collection. Each of these will pass through evaluation and then break a downstream driver.

Audit Logging & Provenance

The artifact and its provenance are inseparable; ship them together or the report is unverifiable. Every generated bundle should carry a manifest recording the input layers and their content hashes, the transformation steps applied, the CRS used for measurement, the versioned rule references, the software versions, and a checksum of each output file. This mirrors the discipline described in the rule engine’s audit approach, where identical inputs must always reproduce identical outputs.

def build_manifest(frame: gpd.GeoDataFrame, artifact_paths: dict[str, str]) -> dict:
    return {
        "generated_utc": datetime.now(timezone.utc).isoformat(),
        "record_count": int(len(frame)),
        "measurement_crs": f"EPSG:{frame['crs_epsg'].iloc[0]}",
        "rule_versions": sorted(frame["rule_version"].dropna().unique().tolist()),
        "artifacts": {name: checksum_bytes(path) for name, path in artifact_paths.items()},
        "software": {"geopandas": gpd.__version__},
    }

Store the manifest alongside the artifacts in append-only storage. When a decision is challenged, the manifest lets an auditor confirm the report was generated from the exact geometry and ordinance revision claimed.

Troubleshooting

  • Row counts differ between source frame and PDF or GeoJSON. A serializer silently dropped null or invalid geometries. Run the sanitize step first and assert len(output) + len(quarantine) == len(source).
  • Areas in the report disagree with the map. Geometry was reprojected before measurement. Measure in the metric CRS, store the scalar, then reproject only for display.
  • Rule version shows as blank. The provenance merge missed rows because rule_id did not join cleanly. Use validate="many_to_one" so an unmatched key raises instead of producing nulls.
  • Checksums change between identical runs. A timestamp or unordered set is embedded in the artifact body. Sort collections and separate volatile metadata into the manifest, not the artifact.
  • CityGML or GML export raises a geometry error the map viewer never showed. OGC-strict drivers reject rings that Leaflet tolerated; repair with make_valid and a zero-width buffer(0) before writing.

Report generation is also the natural place to catch problems the pipeline tolerated but a reader will not: a verdict with no citation, a measurement with no unit, a parcel appearing twice. Validating the rendering context before rendering — and failing loudly rather than printing a blank where a value should be — turns those into build failures instead of embarrassments in a signed document.

Validation at this boundary is also where a missing figure or an unreferenced geometry gets noticed, long before a reviewer opens the file.

Generating Documents That Age Well

A compliance report may be read a decade after it was produced, by someone with none of the context and none of the software. Three choices decide whether it still makes sense then.

Self-containment. Everything the reader needs should be inside the document: the quoted clause, the measured values, the input editions, the rule version. A report that references an internal ticket, a dashboard URL or a shared drive path will be unreadable as soon as any of those move — which, on a ten-year horizon, is all of them.

Format durability. PDF/A exists precisely for this and is worth the small extra effort; a plain PDF with embedded fonts is an acceptable second. What is not acceptable is a format that depends on a rendering service, a font server or a live stylesheet, because the document then has a runtime dependency and will eventually render differently or not at all.

Legible provenance. Hashes and version strings are not beautiful, but they are what makes a document verifiable, and burying them in a tiny footer is a false economy. A short provenance block — inputs, rule pack, run identifier, generation timestamp in UTC — placed consistently, is read by exactly the people who need it and ignored comfortably by everyone else.

Rendering itself should be reproducible too. Given the same verdict records, regenerating a report should produce a byte-identical document, which means excluding the generation timestamp from the hashed content or recording it as data rather than baking it into the layout. Teams that achieve this get a useful property for free: a report can be regenerated to prove it matches the one on file, which is a much stronger position than asserting that it does.

The same validation is worth running against a sample of finished documents rather than only against the context, since a template can drop a field without any code noticing.

Aggregate Views Without Losing the Detail

Most reporting requests start specific — one parcel, one application — and then a manager asks for the summary: how many violations this month, which rules fire most often, which districts generate the most review work. Building those aggregates from the same verdict records rather than from a separate pipeline is what keeps the numbers consistent.

Three aggregates cover most requests. A per-rule frequency view answers which standards are most often breached, which is genuinely useful policy information — a rule that fails ninety per cent of applications is usually a rule people do not know about, or one that is mis-encoded. A per-district workload view shows where review effort concentrates. And a trend over runs view shows whether things are improving, which is the one most often asked for and the one most easily distorted, because a change in the parcel fabric or the rule set moves it as surely as a change in behaviour.

That last hazard is worth guarding explicitly. Any trend chart drawn across runs should be annotated with the rule-version changes and data refreshes that occurred within its window, so a step change is attributable rather than mysterious. An unannotated trend line invites a causal story, and the story is wrong roughly as often as it is right.

Aggregates should never be the only artefact retained. They are derived, they compress away the evidence, and the question they prompt — “which parcels are those?” — can only be answered from the underlying records. Keep the detail; publish the summary.

Templating Without Losing the Numbers

Report generation is a rendering problem, and rendering problems have a well-known failure mode: logic migrating into the template. A template that decides whether a margin counts as marginal, or that rounds a measurement, has taken over part of the compliance decision, and it will do so without review, without tests and without appearing in the rule set.

What belongs upstream of the templateDecisions about rounding, phrasing, severity and ordering belong in the rendering context; templates receive values and lay them out.Decided in the contextLeft to the templateNumbersRounding, units, sign of the marginWhere on the page they appearWordingWhich phrase this outcome getsTypography and emphasisSeverityWhether a margin counts as marginalWhich colour marginal isInclusionWhich findings are reportableOrdering and grouping
Every row on the left is a compliance decision. None of them should be made somewhere with no tests.

The separation that holds up is strict. The verdict record carries everything decided — outcome, measurement, threshold, margin, tolerance, citation, input identity — and the template carries only presentation: ordering, grouping, wording and layout. If the template needs a value, the value is computed upstream and added to the record; if it needs a phrase that depends on a value, the phrase is selected upstream too.

def render_context(verdict, rule, parcel):
    """Everything the template needs, decided before rendering starts."""
    return {
        "parcel_id": parcel.id,
        "citation": rule.citation,
        "clause_text": rule.quoted_text,
        "measured": f"{verdict.measured:.2f} {rule.unit}",
        "required": f"{rule.value:.2f} {rule.unit}",
        "margin": f"{verdict.margin:+.2f} {rule.unit}",
        "outcome": verdict.outcome,              # compliant | violation | indeterminate
        "outcome_phrase": PHRASES[verdict.outcome],   # chosen here, not in the template
        "inputs": [{"layer": s.name, "edition": s.edition, "hash": s.hash[:12]}
                   for s in verdict.sources],
    }

The benefit shows up the first time two formats disagree. When the PDF and the dashboard both render the same context, they cannot disagree; when each computes its own phrasing from raw values, they eventually will, and the discrepancy will be found by a reader rather than by a test.

Writing for the Person Who Disagrees

Most compliance reports are read carefully by exactly one kind of person: someone who thinks the answer is wrong. Writing for that reader improves the document for everyone else too.

Writing for the reader who thinks the answer is wrongSpecific findings, margins in both directions, the quoted clause, the geometry and an explicit statement of what was not covered.The specific finding, not the status"extends 1.8 ft into the required 20 ft setback"The margin, either waycompliant results should say how much room they hadThe quoted clausemoves the argument to interpretation, where planners are expertThe geometrya drawing turns a dispute into a redesignWhat was not coveredrules skipped, parcels indeterminate, snapshots stale
Design for the sceptical reader and the document improves for everyone else too.

Lead with the specific finding rather than the status. “The proposed structure extends 1.8 ft into the required 20 ft front setback (§ 17.24.030(B))” tells the reader what happened; “FAIL — front setback” tells them only that something did. Include the margin in both directions, so a compliant result shows how much room it had, which pre-empts the follow-up question about how close it was.

Quote the clause. A verdict beside the ordinance text it came from moves the conversation to interpretation, where a planner is the expert, rather than to whether the software works, where nobody is.

Show the geometry where there is any. An encroachment described in feet is a claim; the same encroachment drawn against the parcel and the envelope is evidence, and it is usually what turns a dispute into a redesign.

And say what the report does not cover. Rules not evaluated, parcels that returned indeterminate, inputs that fell back to an older snapshot — all of it belongs in the document rather than in a log. A report that quietly omits what it could not do invites the reader to assume it did everything, which is the assumption that eventually gets someone into trouble.

A report is the most visible artefact this pipeline produces and the one people form their opinion of the whole system from, which is a reason to spend more care on it than its share of the code would suggest.

Part of: Compliance reporting and audit trail generation

Conclusion

Audit-ready reporting is the stage where computed compliance becomes institutional record. By freezing the result set, attaching CRS and rule provenance to every row, rendering format-specific artifacts from that single frame, and sealing each bundle with a checksum and manifest, teams produce output that survives scrutiny in hearings and appeals. Continue with the format-specific guides — starting with PDF compliance reports in Python — and pair them with the validation log design module so the provenance this stage prints was captured correctly upstream.