Automated Reporting & GIS Export

The delivery layer is where a scored, ranked set of candidate sites stops being a GeoDataFrame in a notebook and becomes an artifact a real-estate committee, a BI dashboard, or a GIS analyst can actually consume — and doing that reproducibly is harder than it looks.

Every stage upstream of this one — routing, demographic attribution, weighting, ranking — produces intermediate data that only ever lives inside a pipeline. The moment a number leaves the pipeline and lands in front of a decision-maker, it becomes evidence in a capital-allocation argument. That transition demands the same rigor as the computation: deterministic regeneration, explicit coordinate reference systems on every geometry that ships, validated file integrity, and a manifest that ties each deliverable back to the exact ranking run that produced it. This stage of the Suitability Scoring & Site Ranking Models workflow takes the ranked shortlist and fans it out into the formats each audience needs, without ever mutating the underlying scores.

Concept: reporting as a deterministic function of the ranking output

Treat report generation as a pure function. Its inputs are a scored GeoDataFrame, a set of template and format parameters, and a run identifier; its outputs are files. Given identical inputs, it must produce byte-identical — or at least semantically identical — outputs every time. That property is what lets a committee re-run last quarter’s deck against this quarter’s data and trust that any difference is a data difference, not a rendering artifact.

Determinism is easy to break without noticing. A dictionary iteration order, a timestamp baked into a PDF footer, an unsorted shortlist, or floating-point coordinate noise from an unpinned projection library will all make two “identical” runs diff. The delivery layer therefore pins a sort order on the shortlist, freezes any embedded timestamp to the run’s dateModified, and — most importantly — controls coordinate precision explicitly rather than emitting whatever the geometry engine’s default serializer produces.

The other non-negotiable is the coordinate reference system. A deliverable carries geometry into tools the pipeline does not control: Tableau, Power BI, QGIS, ArcGIS Pro. Those tools assume interchange geometry is WGS84 (EPSG:4326), so that is what ships. But any number in the report that describes a geometry — a trade-area size in square kilometres, a distance to the nearest competitor — must have been computed in an equal-area or local projected system, never in degrees. Storing in WGS84 while computing in an equal-area CRS is the same discipline established across the location intelligence architecture: store for interchange, project for math.

Report and GIS export fan-out A ranked, scored GeoDataFrame passes through a single export assembler that pins sort order, coordinate precision, and CRS, then fans out into four validated artifacts — a PDF committee report, an XLSX workbook, a WGS84 GeoJSON, and a multi-layer GeoPackage — each gated on schema, geometry validity, and file integrity before landing in a run-keyed bundle. Report and GIS export fan-out Scored shortlist ranked GeoDataFrame scores · geometry · CRS Export assembler pin sort order set precision assert CRS run manifest PDF report map · scores · demographics XLSX workbook multi-sheet · no geometry GeoJSON WGS84 · RFC 7946 GeoPackage multi-layer · indexed Validate schema geometry integrity Bundle run-keyed Solid arrows = data flow · dashed = validation gates · one assembler, one CRS policy, four audience-specific artifacts

Configuration parameters

Export behavior is driven by a small, explicit configuration rather than scattered call-site arguments. Centralizing it is what makes a run reproducible: the same config plus the same input always yields the same bundle. The table below lists the parameters that materially change what ships.

Parameter Type Valid range / values Default Effect on output
formats list of string pdf, xlsx, geojson, gpkg all four Which artifacts the assembler emits for the run
interchange_crs EPSG code 4326 (fixed for GeoJSON) 4326 CRS geometry is stored/shipped in; WGS84 for interchange
area_crs EPSG code equal-area (e.g. 5070, 6933) 5070 CRS all area/length metrics are computed in
coord_precision int 49 decimal places 6 Coordinate rounding on GeoJSON export; controls file size vs. fidelity
page_template path valid Jinja2/HTML or ReportLab layout committee_a4 Page layout for the PDF committee report
top_n int 1–full shortlist 10 How many ranked sites are rendered into per-site report pages
include_geometry_xlsx bool true / false false Whether XLSX carries WKT geometry or drops it
gpkg_layers list of string layer names ["sites", "trade_areas"] Which layers are written into the GeoPackage container

Six decimal places of longitude/latitude resolve to roughly 0.11 m at the equator — far finer than any trade-area boundary needs, and already generous. Every additional digit past that inflates GeoJSON size without adding decision-relevant fidelity. The precision-versus-size trade-off is quantified in the coordinate-precision section below and is the single biggest lever on GeoJSON payload weight.

Coordinate precision as an explicit budget

Coordinate precision is a numeric decision, not a default to inherit. The ground distance represented by the last retained decimal place of a WGS84 coordinate is, for longitude at latitude ϕ\phi:

Δx111,320cos(ϕ)10d metres\Delta x \approx 111{,}320 \cdot \cos(\phi) \cdot 10^{-d} \text{ metres}

where dd is the number of decimal places retained. At ϕ=40\phi = 40^{\circ}, six decimals (d=6d = 6) resolve to about 0.0850.085 m and seven to about 0.00850.0085 m. Since a scored trade-area polygon derived from routing or block-group aggregation is uncertain at the tens-of-metres scale at best, retaining more than six decimals encodes noise as if it were signal — and pays for it in bytes on every vertex. The assembler therefore rounds coordinates to the configured coord_precision before serialization, deterministically, so two runs of the same geometry serialize identically.

Report templating and reproducible layout

A committee deliverable is not free-form — it is a template populated with data. Separating the layout (a Jinja2 HTML page, a ReportLab canvas, or a spreadsheet skeleton) from the data that fills it is what makes the report both reproducible and maintainable. Change the data, and the same template renders a new deck; change the template, and every past run can be regenerated in the new layout because the underlying scored frame is preserved and version-controlled.

The template contract is a fixed set of named slots — a title, a rank, a score, a map placeholder, a demographics table — that the assembler fills from known columns. Keeping that contract stable across runs means a quarterly cadence produces visually consistent documents, so a reviewer comparing this quarter to last is reading a data change, not a layout change. Two rules keep templated output deterministic: never let the template call now() (freeze any date to the run’s dateModified), and never rely on dictionary or set iteration order for anything a reader sees — sort every collection before it enters a slot. The per-audience layouts diverge from here: the PDF template is a print-oriented page, while the XLSX “template” is a fixed sheet structure with named columns and a conditional-format rule.

Step-by-step: assembling a report bundle from a scored GeoDataFrame

The assembler below is the core of the delivery layer. It takes a ranked, scored GeoDataFrame, asserts its CRS, computes area metrics in an equal-area projection, and dispatches to per-format writers — never mutating the source frame. Each writer returns the path it wrote, and a manifest records every artifact against the run identifier so the bundle is fully traceable back to the ranking and shortlisting run that produced it.

python
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path

import geopandas as gpd
from pyproj import CRS

INTERCHANGE_CRS = CRS.from_epsg(4326)   # WGS84, for storage + GeoJSON interchange
AREA_CRS = CRS.from_epsg(5070)          # NAD83 / Conus Albers, equal-area metrics


@dataclass
class ExportConfig:
    out_dir: Path
    run_id: str
    formats: tuple[str, ...] = ("pdf", "xlsx", "geojson", "gpkg")
    coord_precision: int = 6
    top_n: int = 10
    include_geometry_xlsx: bool = False


def prepare_frame(sites: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Deterministic, CRS-checked view of the ranked shortlist."""
    assert sites.crs is not None, "scored sites have no CRS; refusing to export"
    if CRS.from_user_input(sites.crs) != INTERCHANGE_CRS:
        sites = sites.to_crs(INTERCHANGE_CRS)     # ship geometry in WGS84

    # Area metrics MUST be computed in an equal-area CRS, never in degrees.
    metric = sites.to_crs(AREA_CRS)
    sites = sites.copy()
    sites["trade_area_km2"] = metric.geometry.area / 1_000_000.0

    # Pin a stable sort so every run orders sites identically.
    return sites.sort_values(["suitability_score", "site_id"],
                             ascending=[False, True]).reset_index(drop=True)


def _sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def assemble_bundle(sites: gpd.GeoDataFrame, cfg: ExportConfig,
                    writers: dict) -> dict:
    """Fan a scored shortlist out to every configured format, with a manifest."""
    frame = prepare_frame(sites)
    cfg.out_dir.mkdir(parents=True, exist_ok=True)
    manifest = {"run_id": cfg.run_id, "crs": "EPSG:4326",
                "area_crs": "EPSG:5070", "artifacts": []}

    for fmt in cfg.formats:
        writer = writers[fmt]                     # e.g. write_pdf, write_geojson
        path = writer(frame, cfg)                 # each returns the file it wrote
        manifest["artifacts"].append({
            "format": fmt,
            "path": str(path),
            "bytes": path.stat().st_size,
            "sha256": _sha256(path),              # integrity fingerprint
        })

    manifest_path = cfg.out_dir / f"manifest_{cfg.run_id}.json"
    manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True))
    return manifest

The function never touches sites in place — prepare_frame copies before adding derived columns, so a caller that reuses the frame downstream sees no side effects. The manifest’s per-artifact SHA-256 is what makes “did this file change?” a one-line comparison rather than a visual diff, and it is the anchor the file-integrity gate checks against.

Each writer is a thin, format-specific function with a single responsibility. The three child workflows below each implement one of them in full:

Format matrix: matching artifact to audience

Each audience consumes a different format, and forcing one format on all of them guarantees friction. The matrix below maps deliverable to consumer and captures the geometry policy each demands.

Format Primary audience Geometry CRS on export Notes
PDF Real-estate / investment committee Rendered thumbnail only Map drawn from WGS84 Human-readable, print-stable, no live data
XLSX Finance, asset management Dropped or WKT column n/a (attributes) or WKT in WGS84 Sortable, pivotable, formula-friendly
GeoJSON Tableau, Power BI, web maps Full polygons EPSG:4326, RFC 7946 Text interchange; watch precision bloat
GeoPackage QGIS, ArcGIS Pro, desktop GIS Full multi-layer Any single CRS, indexed SQLite container; carries an R-tree index

The rule of thumb: text formats (GeoJSON) for web and BI ingestion, binary containers (GeoPackage) for desktop GIS work, and rendered formats (PDF) for humans who will never open a shapefile. XLSX sits apart — it is an attribute deliverable that usually drops geometry entirely, because a lease-committee spreadsheet cares about the score and the demographics, not the vertices.

Edge cases and failure modes

Export failures are usually silent: the file writes successfully but is subtly wrong. Watch these patterns.

  • Huge geometries. A trade area assembled from thousands of block-group fragments can carry hundreds of thousands of vertices. Shipped raw to GeoJSON, a single feature can exceed tens of megabytes and stall a BI import. Simplify with a topology-preserving tolerance (gdf.geometry.simplify(tol, preserve_topology=True)) in the projected CRS before reprojecting back to WGS84 for export, and record the tolerance in the manifest so the simplification is auditable.
  • Coordinate precision bloat. Serializers that emit full float64 precision produce 15-digit coordinates that quadruple file size and imply millimetre accuracy the data does not have. Round to coord_precision deterministically before writing.
  • Encoding. Store names and street addresses routinely contain non-ASCII characters. Write every text artifact as UTF-8 explicitly; a default cp1252 on Windows corrupts accented characters in XLSX and breaks JSON parsing downstream.
  • Mixed geometry types. A shortlist that mixes Polygon and MultiPolygon breaks some strict GeoJSON consumers and every single-geometry-type GIS layer. Normalize to MultiPolygon before export.
  • CRS drift on round-trip. Reprojecting to compute area and forgetting to ship the original WGS84 geometry leaves projected coordinates in an interchange file — a classic silent corruption caught only by the round-trip validation gate below.

Performance and scaling

At the scale of a single site-selection cycle — tens to low hundreds of ranked candidates — export is I/O-bound, not CPU-bound, and the dominant cost is geometry serialization. Three tactics keep it fast.

  1. Simplify before serializing. Vertex count drives both file size and write time. A topology-preserving simplification in the projected CRS is the highest-leverage optimization for any format carrying full geometry.
  2. Stream large collections. For GeoPackage and GeoParquet, write in row-group chunks rather than materializing a giant serialized string in memory. The columnar storage patterns for the analytical side of this live in configuring AWS S3 for geospatial data lakes.
  3. Parallelize across formats, not within. The four writers are independent and can run concurrently, but each individual serializer is best left single-threaded to keep output deterministic.

For the analytical layer — where scored trade areas are stored for repeated querying rather than one-shot delivery — write partitioned GeoParquet to object storage and index the operational copy in PostGIS rather than regenerating deliverables on every dashboard load.

Validation and QA gates

No artifact reaches a stakeholder until it clears these gates. Run them as hard failures in the pipeline, not advisory logs.

  1. Schema conformance. Every deliverable carries the agreed columns — site_id, rank, suitability_score, trade_area_km2, and the demographic fields — with the expected dtypes. A missing or renamed column fails the run.
  2. Geometry validity. Every exported geometry passes geom.is_valid; repair with make_valid() or reject. Invalid rings that survive into a GeoPackage corrupt the spatial index.
  3. CRS assertion. Interchange artifacts are EPSG:4326; the assertion is in code, so a projected-coordinate leak fails loudly.
  4. File integrity. Re-read every file after writing (round-trip), confirm the feature count matches the source, and record the SHA-256 in the manifest. A truncated write or an encoding error surfaces here, not on the stakeholder’s screen.
python
import geopandas as gpd
from pyproj import CRS


def validate_artifact(path, expected_n: int, expect_crs=None,
                      required_cols=("site_id", "rank", "suitability_score")):
    """Round-trip a written file and gate on count, schema, CRS, validity."""
    gdf = gpd.read_file(path)
    assert len(gdf) == expected_n, f"{path}: {len(gdf)} rows, expected {expected_n}"
    missing = set(required_cols) - set(gdf.columns)
    assert not missing, f"{path}: missing columns {missing}"
    if expect_crs is not None:
        assert CRS.from_user_input(gdf.crs) == CRS.from_user_input(expect_crs), \
            f"{path}: CRS {gdf.crs} != {expect_crs}"
    if gdf.geometry.notna().any():
        assert gdf.geometry.is_valid.all(), f"{path}: invalid geometry survived export"
    return True

Integration notes: closing the loop with the ranking output

The delivery layer is the terminus of the scoring pipeline, but it is not a dead end. The manifest it writes is the audit record that ties a committee deck back to the exact ranking weights and input data that produced it — which matters when a decision is challenged months later. Because the shortlist is produced by building weighted site suitability scores and ordered by the ranking stage, any change to a weight or an input propagates through to a materially different bundle, and the manifest’s SHA-256 fingerprints make that change visible.

Keep the export configuration under version control alongside the scoring weights. A quarterly site-selection cycle depends on reproducible baselines, and an undocumented change to a page template or a coordinate-precision setting silently shifts what the committee sees. Persist bundles to object storage keyed by run_id, and treat the manifest as the contract between the analytical pipeline and every downstream consumer.

Frequently Asked Questions

Which CRS should geometry be exported in?

Interchange geometry ships in WGS84 (EPSG:4326) because that is what BI tools and web maps assume — and GeoJSON mandates it. But never compute area, length, or distance in EPSG:4326: reproject to an equal-area system such as EPSG:5070 for those metrics, then store the WGS84 geometry. Storing WGS84 and computing in an equal-area CRS is the whole discipline.

How do I make report generation deterministic?

Pin a sort order on the shortlist, freeze any embedded timestamp to the run’s modification date rather than now(), round coordinates to a fixed precision before serializing, and normalize geometry types. Then fingerprint every output with a SHA-256 in the manifest so two runs of identical inputs are provably identical.

Should XLSX include geometry?

Usually not. A finance or committee spreadsheet cares about the score, rank, and demographics, and raw vertices only clutter it. Drop geometry by default; if a downstream tool needs it, encode a single WKT column in WGS84 rather than exploding coordinates across cells.

How large is too large for a GeoJSON deliverable?

Any single feature past a few megabytes will slow or break a BI import. If a trade-area polygon carries tens of thousands of vertices, simplify it with a topology-preserving tolerance in a projected CRS before export, and record the tolerance in the manifest. Combined with six-decimal coordinate precision, that keeps files well within what Tableau and Power BI ingest comfortably.

← Back to Suitability Scoring & Site Ranking Models