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.
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.