Exporting Compliance Results to GeoJSON and CityGML
Permitting portals, municipal GIS servers, and 3D city models each expect compliance findings in a machine-readable spatial format rather than a printed page. This guide serializes a frozen result set to GeoJSON for lightweight web exchange and to CityGML or CityJSON for building-level urban models, while preserving the coordinate reference system metadata that makes the export legally meaningful. It is a task within the audit-ready report generation module, and it produces the same verdicts your PDF compliance reports print.
Prerequisites
Step-by-step
Step 1: Confirm CRS and normalize the output schema
Start from the evaluation output and lock the CRS before anything else. GeoJSON per the specification expects WGS84, so record the measurement CRS as an attribute before reprojecting, or the export loses provenance.
import geopandas as gpd
MEASURE_CRS = "EPSG:25832" # ETRS89 / UTM 32N (meters), common for German municipal data
def prepare_export(path: str) -> gpd.GeoDataFrame:
gdf = gpd.read_file(path)
if gdf.crs is None:
raise ValueError("Result set has no CRS; cannot export defensibly")
# Persist the measurement CRS as data so reprojection can't erase provenance.
gdf["measure_epsg"] = gdf.crs.to_epsg()
gdf["area_sqm"] = gdf.geometry.area # computed in the metric CRS, before any reprojection
keep = ["parcel_id", "status", "rule_id", "rule_version",
"measured_m", "required_m", "area_sqm", "measure_epsg", "geometry"]
return gdf[keep]
Step 2: Write GeoJSON in WGS84 with preserved metadata
Reproject a copy to EPSG:4326 for the GeoJSON body, but keep the measure_epsg column so a consumer can see which CRS the distances were measured in. GeoPandas to_file with the GeoJSON driver handles the write.
def export_geojson(gdf: gpd.GeoDataFrame, out_path: str) -> None:
# RFC 7946 GeoJSON is defined in WGS84 lon/lat; reproject geometry only.
wgs84 = gdf.to_crs("EPSG:4326")
# Round coordinates modestly to keep the file reproducible and compact.
wgs84.to_file(out_path, driver="GeoJSON", COORDINATE_PRECISION=7)
Because the RFC 7946 GeoJSON standard fixes the coordinate system, the measure_epsg attribute is the only record of where the metrics came from — never drop it. See the GeoPandas I/O documentation for driver options and the RFC 7946 specification for the WGS84 requirement.
Step 3: Export CityGML or CityJSON for 3D models
For building-level urban models, write GML through GDAL or produce CityJSON with cjio. CityGML, unlike GeoJSON, retains an explicit CRS, so keep the projected system rather than flattening to WGS84.
def export_citygml(gdf: gpd.GeoDataFrame, out_path: str) -> None:
# Keep the projected CRS: CityGML records srsName explicitly, so 3D
# measurements stay in meters rather than degrees.
projected = gdf.to_crs(MEASURE_CRS)
# GDAL's GML driver writes an OGC-compliant document with srsName populated.
projected.to_file(out_path, driver="GML")
If your consumer expects CityJSON specifically, convert the GML with cjio or map attributes onto CityJSON CityObjects, preserving the referenceSystem field with the full EPSG URN.
Step 4: Verify CRS metadata survived the round trip
Re-read each export and assert the CRS is what you intended and that the provenance attribute is intact.
def verify_exports(geojson_path: str, gml_path: str) -> None:
gj = gpd.read_file(geojson_path)
assert gj.crs.to_epsg() == 4326, "GeoJSON must be WGS84"
assert "measure_epsg" in gj.columns, "Lost measurement-CRS provenance"
gml = gpd.read_file(gml_path)
assert gml.crs.to_epsg() == 25832, "CityGML lost its projected CRS"
print(f"GeoJSON features: {len(gj)}, GML features: {len(gml)}")
Verification
Beyond CRS checks, confirm no records were dropped and that a known failing parcel serialized with its non-compliant status in both formats.
def cross_check(source: gpd.GeoDataFrame, geojson_path: str) -> None:
out = gpd.read_file(geojson_path)
assert len(out) == len(source), "Feature count drift during GeoJSON write"
src_fail = set(source.loc[source["status"] != "COMPLIANT", "parcel_id"])
out_fail = set(out.loc[out["status"] != "COMPLIANT", "parcel_id"])
assert src_fail == out_fail, "Status mismatch after export"
print("Export cross-check passed")
Common Pitfalls
- Measuring area after reprojecting to WGS84.
geometry.areaon degrees returns meaningless square-degree values. Computearea_sqmin the projected CRS first, then reproject geometry for GeoJSON. - Assuming GeoJSON remembers your CRS. RFC 7946 mandates WGS84, so the original EPSG code is gone unless you store it as an attribute. Always carry
measure_epsg. - Silent geometry drops on GML write. The OGC-strict GML driver rejects invalid rings that GeoJSON tolerated. Run
make_validand a zero-width buffer before exporting, and assert equal feature counts across formats.
Which Geometry Goes in the Export?
An export is more useful when it carries the geometry that explains the verdict rather than only the parcel it concerns, and there are usually three candidates worth including.
The parcel locates the result and is what a recipient joins against their own data. The evaluated envelope or buffer shows the standard being applied, which is what turns a number into something a designer can work against. And the failing geometry — the encroaching sliver, the portion of a footprint inside a constraint zone — is the specific evidence, and it is the layer reviewers open first.
Exporting all three as separate features with a shared parcel identifier and a role attribute keeps the file simple and lets a recipient style them independently. Bundling them into one multi-part geometry is smaller and considerably less useful, because the parts can no longer be told apart.
CRS in an Interchange Format
GeoJSON is specified to be in WGS84, and this is where a careful pipeline can undo itself at the last step.
The rule that works is to compute in the projected working frame, export in WGS84, and record both. Reprojecting for export is a presentation step performed after every measurement is complete, so it cannot affect a verdict — but the measured values in the properties are in the working frame’s units and must say so. A distance_ft property beside coordinates in degrees is not a contradiction provided the unit is named; a bare distance beside them is an invitation to misread.
CityGML carries a CRS declaration and does not share GeoJSON’s constraint, which makes it the better choice where the recipient needs the geometry in the frame it was measured in — usually another agency’s GIS. Its cost is complexity: it is a much larger specification, and producing a valid document that a specific recipient’s software will open is a compatibility exercise rather than a serialisation one.
def export_geojson(verdicts, working_crs, out_path):
"""Reproject for interchange at the last moment; keep the measured units named."""
gdf = to_geodataframe(verdicts, crs=working_crs)
gdf["distance_ft"] = gdf["measured"] # named unit, not a bare "distance"
gdf["measured_in_crs"] = working_crs # the frame the number came from
gdf.to_crs("EPSG:4326").to_file(out_path, driver="GeoJSON")
Properties a Recipient Can Actually Use
An export’s attribute schema is an interface, and it should be treated with the same care as an API.
Name the units in the field names or in a companion field, never leave them implied. Use a stable, documented set of outcome values, including the indeterminate one. Carry the citation and the rule version so a recipient can tell what standard was applied. Include the parcel identifier the recipient uses, not only your internal key. And version the schema itself, so a consumer can detect a change rather than discovering it through a broken map.
Keep the property set small and stable in preference to comprehensive and evolving. Every field a consumer reads is a commitment, and removing one later breaks their workflow — which, for an agency exchanging data with another agency, is a considerably bigger problem than omitting it in the first place.
Related
Part of: Audit-ready report generation
- Generating PDF compliance reports with Python — the human-readable sibling of this export.
- Building interactive HTML compliance dashboards — the triage view over the same records.
- Cloud-native geospatial formats for compliance pipelines — formats for storage rather than interchange.
- CRS standardization and datum management — why the export reprojection is safe and the working one is not.
Frequently Asked Questions
Why does GeoJSON not preserve my projected coordinate system?
The GeoJSON standard, RFC 7946, defines all coordinates in WGS84 longitude and latitude and removed the older CRS member. Writing GeoJSON therefore reprojects your data to EPSG:4326, and the original projected system is not recorded anywhere in the geometry. The fix is to store the measurement EPSG code as a feature attribute before you reproject, so any consumer can see which CRS the compliance distances were computed in.
When should I choose CityGML or CityJSON over GeoJSON?
Use GeoJSON for lightweight web exchange, portal ingestion, and 2D parcel footprints. Reach for CityGML or CityJSON when the consumer maintains a semantic 3D city model and needs building-level objects, level-of-detail structure, and an explicit reference system. CityJSON is the more compact, developer-friendly JSON encoding of the same CityGML data model, and it retains the projected CRS that 3D measurements depend on.
How do I keep area and distance values correct across both formats?
Compute every metric once, in the projected metric CRS, and store the results as plain numeric attributes such as area in square meters and distance in meters. Those numbers then travel unchanged into whatever format you write, regardless of how the geometry itself is reprojected for display. Never recompute area from GeoJSON coordinates, because those are degrees.
Can I round coordinates to shrink the file without breaking audits?
Yes, within reason. Seven decimal places of longitude and latitude is roughly centimeter precision, which is finer than most parcel surveys, so COORDINATE_PRECISION=7 produces compact, reproducible files without materially affecting compliance geometry. Avoid aggressive rounding that could shift a boundary across a setback line.
For a reviewer-facing view of the same exported layers, continue to building interactive HTML compliance dashboards, and ensure the provenance you serialize here matches what the validation log design module captured upstream.