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.
- 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. UploadingEPSG:5070easting/northing values (six- and seven-digit meters) trips the portal’s-180..180 / -90..90range check immediately. The reproject must happen on export, never in the analysis. - Coordinate precision bloat. A
float64reprojection 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. - NaN and Timestamp values are not JSON-serializable. Compliance overlays carry computed columns — a
setback_mthat isNaNwhere no ordinance applies, or apandas.Timestampaudit date.NaNserializes to the bare tokenNaN, which is invalid JSON per the spec, andTimestampraisesTypeError: Object of type Timestamp is not JSON serializable. Either one produces a file that fails strict parsing on the server. - Property-schema and geometry-type mismatch. Portals require named property keys with specific types (a string
parcel_id, a numericsetback_m), and some rejectFeatureCollections that mixPolygonandMultiPolygon. An overlay that lost a column during a dissolve, or that carries mixed geometry types, is structurally valid GeoJSON but invalid to that portal.
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.
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.
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. Thepyogrioengine 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
FeatureCollectionbyjurisdiction_typeand 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 forceMultiPolygonuniformly, before export — decide once, at the boundary, not per feature. - Pin
shapely >= 2.0,geopandas >= 0.14, and the GDAL build.set_precisionand theRFC7946driver 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.
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.
Related
- Regulatory Boundary Mapping — the parent workflow that produces the compliance overlay exported here.
- Clipping Solar Parcels to County Setback Boundaries in GeoPandas — the overlay computation whose result this export consumes.
- Coordinate Reference Systems for Energy Projects — projected-vs-geographic CRS choice the reproject step inverts.
- Proximity Distance Calculations — grid-side feasibility scoring with the same lineage-tagging discipline.