Exporting Compliance Overlay Results to GeoJSON for Permitting Portals

You have a computed setback and exclusion overlay — a GeoDataFrame of resolved compliance geometry sitting in a metric CRS such as EPSG:5070 (CONUS Albers) — and the permitting portal’s upload form rejects it: “Invalid GeoJSON: coordinates out of range”, “Unsupported CRS”, or a silent server-side failure that leaves the submission stuck in review. This is the last mile of Regulatory Boundary Mapping: the overlay math is correct, but the file handed to the agency is not the file the agency’s schema accepts. RFC 7946 — the GeoJSON specification every modern permitting portal validates against — mandates EPSG:4326 longitude/latitude, bounded coordinate ranges, and JSON-serializable properties, none of which your analysis CRS satisfies by default. This page walks the end-to-end export: assembling the overlay frame, attaching compliance flags and provenance, reprojecting to EPSG:4326, trimming precision, coercing properties, validating against the portal schema, and writing a conformant file a reviewer can trust.

The overlay itself is typically produced by clipping solar parcels to county setback boundaries; everything here assumes that clip has already run and you hold its result in memory.

Root-cause analysis

Four compounding causes turn a valid overlay into a rejected upload, and each maps to a distinct fix stage below.

  1. Analysis CRS is metric; RFC 7946 demands EPSG:4326. Setback distances only make sense in a projected, meter-based frame — you cannot buffer 500 m in degrees. But RFC 7946 fixes the coordinate reference system to EPSG:4326 (WGS 84 lon/lat) and forbids embedding an alternate CRS. Uploading EPSG:5070 easting/northing values (six- and seven-digit meters) trips the portal’s -180..180 / -90..90 range check immediately. The reproject must happen on export, never in the analysis.
  2. Coordinate precision bloat. A float64 reprojection emits ~15 significant digits per ordinate. A statewide exclusion layer at that precision is a multi-hundred-megabyte file that many portals reject on size alone, and the trailing digits are noise far below survey accuracy. RFC 7946 explicitly recommends six decimal places.
  3. NaN and Timestamp values are not JSON-serializable. Compliance overlays carry computed columns — a setback_m that is NaN where no ordinance applies, or a pandas.Timestamp audit date. NaN serializes to the bare token NaN, which is invalid JSON per the spec, and Timestamp raises TypeError: Object of type Timestamp is not JSON serializable. Either one produces a file that fails strict parsing on the server.
  4. Property-schema and geometry-type mismatch. Portals require named property keys with specific types (a string parcel_id, a numeric setback_m), and some reject FeatureCollections that mix Polygon and MultiPolygon. An overlay that lost a column during a dissolve, or that carries mixed geometry types, is structurally valid GeoJSON but invalid to that portal.
Compliance-overlay to RFC 7946 GeoJSON export pipeline A left-to-right flow of five stages. The compliance overlay in EPSG:5070 metric coordinates enters the reproject stage that converts it to EPSG:4326, then a trim-precision stage that rounds to six decimals, then a serialize-properties stage that converts NaN to null and Timestamp to ISO strings, ending in a green stage that validates the portal schema and writes an RFC 7946 GeoJSON. Amber callouts beneath the middle three stages name the failure each prevents. Compliance overlay EPSG:5070 (metric) flags + provenance Reproject to EPSG:4326 Trim precision 6 decimals Serialize props NaN → null Timestamp → ISO Validate + write RFC 7946 GeoJSON degrees ≠ meters: non-4326 file rejected on range 15-digit floats: coordinate bloat, oversized upload NaN / Timestamp: not JSON- serializable PREVENTS PREVENTS PREVENTS

The precision recommendation is not arbitrary. The ground length of one degree of longitude at latitude is

so at six decimal places ( degrees) the quantization is roughly meters — about 11 cm at the equator and finer toward the poles. That is already below the accuracy of any parcel survey feeding a permitting overlay, which is why RFC 7946 treats further digits as noise.

Pre-flight validation

Surface every cause above before writing a byte. The validator below inspects the in-memory overlay against the portal’s contract — a required-property schema, allowed geometry types, and the guarantee that the source CRS is projected (so the reproject is meaningful) — and raises a precise error rather than letting the portal reject the upload hours later.

python
import geopandas as gpd
import numpy as np
import pandas as pd

# The portal's declared property contract: column -> accepted pandas dtype kind
PORTAL_SCHEMA = {
    "parcel_id": "O",        # object / string
    "compliance_status": "O",
    "setback_m": "f",        # float
    "jurisdiction_type": "O",
}
ALLOWED_GEOM_TYPES = {"Polygon", "MultiPolygon"}


def preflight_geojson_export(overlay_gdf: gpd.GeoDataFrame) -> None:
    """Raise on the exact root cause before serialization begins."""
    # Cause 1: source CRS must be defined and projected so the reproject is real
    if overlay_gdf.crs is None:
        raise ValueError("Overlay has no CRS; cannot reproject to EPSG:4326 safely.")
    if overlay_gdf.crs.is_geographic:
        raise ValueError(
            f"Source CRS {overlay_gdf.crs.to_epsg()} is already geographic. "
            "Compliance setbacks must be computed in a metric CRS (e.g. EPSG:5070)."
        )
    # Cause 4: property schema — required keys present with the declared kind
    missing = set(PORTAL_SCHEMA) - set(overlay_gdf.columns)
    if missing:
        raise ValueError(f"Overlay missing portal-required properties: {sorted(missing)}")
    for col, kind in PORTAL_SCHEMA.items():
        if overlay_gdf[col].dtype.kind != kind:
            raise TypeError(f"Property '{col}' has kind {overlay_gdf[col].dtype.kind!r}, "
                            f"portal expects {kind!r}.")
    # Cause 4: geometry-type homogeneity guard
    types = set(overlay_gdf.geom_type.unique())
    if not types <= ALLOWED_GEOM_TYPES:
        raise ValueError(f"Unsupported geometry types for portal: {types - ALLOWED_GEOM_TYPES}")
    # Cause 3: warn on non-serializable values so the coercion step is not a surprise
    ts_cols = [c for c in overlay_gdf.columns
               if pd.api.types.is_datetime64_any_dtype(overlay_gdf[c])]
    if ts_cols:
        print(f"[preflight] datetime columns {ts_cols} will be coerced to ISO 8601 strings.")
    if overlay_gdf.select_dtypes("number").isna().to_numpy().any():
        print("[preflight] NaN values present in numeric properties; will be written as null.")
Validation step Diagnostic Expected outcome
Source CRS is projected overlay_gdf.crs.is_geographic False — setbacks were computed in meters
Required properties present set(PORTAL_SCHEMA) <= set(overlay_gdf.columns) True
Geometry types allowed set(overlay_gdf.geom_type.unique()) subset of {Polygon, MultiPolygon}
Geometry validity overlay_gdf.geometry.is_valid.all() True — no self-intersections survive to export

Enforcing a projected source is the same coordinate reference system alignment discipline the overlay math depended on; the export inverts it, moving deliberately back to EPSG:4326 only at the boundary.

Building the RFC 7946 export function

The export function runs the sub-tasks in order: attach compliance flags and provenance, reproject to EPSG:4326, snap geometry to a six-decimal grid, coerce non-serializable properties, then write with GDAL’s RFC7946=YES option so winding order and axis order match the specification. Parameter choices are justified for permitting use: set_precision(grid_size=1e-6) matches the six-decimal recommendation, RFC7946=YES forces right-hand-rule exterior rings, and provenance columns make the file self-describing for a reviewer.

python
import json
from datetime import datetime, timezone

import geopandas as gpd
import numpy as np
import pandas as pd
from shapely import set_precision


def export_compliance_geojson(
    overlay_gdf: gpd.GeoDataFrame,
    out_path: str,
    source_epsg: int = 5070,
    grid_size_deg: float = 1e-6,   # ~0.11 m at the equator; RFC 7946 6-decimal rule
    run_id: str = "overlay-export",
) -> gpd.GeoDataFrame:
    """Reproject, trim, coerce, and write an RFC 7946 GeoJSON for a permitting portal."""
    preflight_geojson_export(overlay_gdf)
    gdf = overlay_gdf.copy()

    # 1. Attach provenance so the exported file is self-auditing
    gdf["source_epsg"] = source_epsg
    gdf["exported_at"] = datetime.now(timezone.utc).isoformat()
    gdf["export_run_id"] = run_id

    # 2. Reproject to EPSG:4326 — the ONLY CRS RFC 7946 permits (Cause 1)
    gdf = gdf.to_crs(epsg=4326)

    # 3. Snap coordinates to a 6-decimal grid to kill float64 precision bloat (Cause 2)
    gdf["geometry"] = set_precision(gdf.geometry.values, grid_size=grid_size_deg)
    gdf = gdf[~gdf.geometry.is_empty & gdf.geometry.is_valid].copy()

    # 4. Coerce non-JSON-serializable property values (Cause 3)
    for col in gdf.columns:
        if col == gdf.geometry.name:
            continue
        if pd.api.types.is_datetime64_any_dtype(gdf[col]):
            gdf[col] = gdf[col].dt.strftime("%Y-%m-%dT%H:%M:%SZ")
        elif pd.api.types.is_float_dtype(gdf[col]):
            # NaN -> None so it serializes to JSON null, not the invalid `NaN` token
            gdf[col] = gdf[col].astype(object).where(gdf[col].notna(), None)

    # 5. Write RFC 7946 GeoJSON: right-hand winding, WGS84 axis order, trimmed precision
    gdf.to_file(
        out_path,
        driver="GeoJSON",
        engine="pyogrio",
        RFC7946="YES",
        COORDINATE_PRECISION=6,
    )
    return gdf

Two details carry the correctness. set_precision with grid_size=1e-6 snaps every ordinate before writing, so the file is small and reproducible regardless of the reprojection’s floating-point tail; and the NaN-to-None conversion is done on an object-dtype copy because a float64 column cannot hold None — assigning None back into a float column silently re-coerces it to NaN. Layering COORDINATE_PRECISION=6 on top of set_precision is belt-and-suspenders: the snap fixes the geometry object, the GDAL option fixes the serialized text.

Fallback routing and performance tuning

  • Stream statewide layers with pyogrio. The pyogrio engine writes in a single vectorized pass; for exclusion layers with hundreds of thousands of features it is several times faster than the legacy Fiona path and keeps peak memory bounded.
  • Simplify before you trim, not instead. If the file is still oversized after six-decimal snapping, apply a topology-preserving gdf.geometry.simplify(tolerance, preserve_topology=True) with a tolerance justified against survey accuracy — never rely on precision trimming alone to shrink a dense parcel boundary.
  • Split by jurisdiction for portal upload limits. Many portals cap upload size. Partition the FeatureCollection by jurisdiction_type and submit per-agency files rather than one monolith; each still carries its own provenance.
  • Promote to a single geometry type when the portal is strict. If a portal rejects mixed geometries, run gdf.explode(index_parts=False) to split multiparts, or force MultiPolygon uniformly, before export — decide once, at the boundary, not per feature.
  • Pin shapely >= 2.0, geopandas >= 0.14, and the GDAL build. set_precision and the RFC7946 driver option depend on those versions; a downgraded environment silently drops the winding-order fix and reintroduces range-check rejections.

Downstream GeoJSON-integrity assertion

Re-open the written file and assert it against the contract before it is submitted — this is the gate that belongs in a CI/CD pipeline so a regression in the overlay code cannot ship a non-conformant permitting artifact.

python
import json
import re

import geopandas as gpd


def assert_geojson_integrity(out_path: str, required_props: set[str]) -> None:
    """CI/CD gate: fail the build if the exported file is not portal-ready."""
    reloaded = gpd.read_file(out_path)
    # RFC 7946 mandates EPSG:4326
    assert reloaded.crs is not None and reloaded.crs.to_epsg() == 4326, \
        f"exported CRS is {reloaded.crs}, RFC 7946 requires EPSG:4326"
    # Coordinates must fall inside the geographic domain
    minx, miny, maxx, maxy = reloaded.total_bounds
    assert -180 <= minx and maxx <= 180 and -90 <= miny and maxy <= 90, \
        f"coordinates out of WGS84 range: {reloaded.total_bounds}"
    # Provenance and portal properties survived the round trip
    assert required_props <= set(reloaded.columns), \
        f"missing properties after export: {required_props - set(reloaded.columns)}"

    # Parse the raw text: reject NaN/Infinity tokens and check coordinate precision
    with open(out_path, "r", encoding="utf-8") as fh:
        raw = fh.read()
    json.loads(raw)  # strict parse: raises on the bare NaN token
    assert not re.search(r":\s*(NaN|Infinity|-Infinity)\b", raw), \
        "non-JSON NaN/Infinity token present in output"
    over_precision = re.findall(r"-?\d+\.\d{8,}", raw)
    assert not over_precision, f"{len(over_precision)} ordinates exceed 6-decimal precision"

Parsing the raw text with json.loads is deliberate: geopandas.read_file is tolerant and will happily reload a file the portal’s strict parser rejects, so the string-level NaN/Infinity and precision checks catch exactly the failures a permissive reader hides. Logging the feature count, bounds, and export_run_id alongside this assertion gives a permitting reviewer the same reproducible audit trail that distance-based screens carry through proximity distance calculations — every submitted file traceable to the overlay state and code revision that produced it.