Aligning Local GIS Data with INSPIRE Standards
You have a local GIS dataset — municipality boundaries, land-use polygons, or utility networks — and a national reporting deadline requiring INSPIRE-compliant GML. The data uses a local coordinate reference system (CRS), attribute names that bear no resemblance to INSPIRE application schema fields, and topology that has never been formally validated against European spatial data rules. Manual reconciliation takes days and produces inconsistent results. This page describes a deterministic, automated four-gate pipeline that enforces European Terrestrial Reference System 1989 (ETRS89) CRS, maps attributes to INSPIRE schemas, validates topology, and generates ISO 19115 metadata — all before a single byte reaches the national reporting node. For the broader approach to translating regulatory standards into executable rules, see Compliance Framework Alignment.
The Four-Gate Pipeline
The diagram below shows how data flows through the four sequential validation gates. A dataset must pass each gate before advancing; failures are logged to a structured JSON report and routed back to the responsible data steward.
Prerequisites
Before running the pipeline, confirm the following are in place. Missing any item causes silent failures that are difficult to trace later.
- Python 3.11+ with
geopandas 0.14+,pyproj 3.6+,shapely 2.0+, andlxml 4.9+installed in a dedicated virtual environment. Pin versions inrequirements.txtto guarantee reproducible transforms across machines. - GDAL 3.6+ on the system path. The
ogr2ogrbinary is required for GML 3.2.1 serialisation at gate 4. Verify withogr2ogr --version. - Network access or a local cache of PROJ datum grids. Run
import pyproj; pyproj.network.set_network_enabled(True)once to download required NTv2 files, or pre-download theproj-datapackage for air-gapped environments. - Source schema documentation: know the field names, types, and value domains of your local dataset before writing codelist mappings. A missing field discovered at gate 2 triggers a full re-run.
- INSPIRE application schema for your theme: download the relevant GML application schema XSD from the INSPIRE schema repository and keep it alongside the pipeline scripts.
Step-by-Step Procedure
Step 1 — CRS Enforcement and Datum Transformation
INSPIRE mandates ETRS89 (EPSG:4258) for geographic coordinate storage in submitted GML. For pan-European analysis, ETRS89-LAEA (EPSG:3035) is the standard projected system; for local projected work, ETRS89-TM zone projections such as EPSG:25832 (UTM Zone 32N) apply. Local grids — OSGB36, RGF93, DHDN, or state plane zones — must be transformed using official Helmert parameters or NTv2 grids. The coordinate reference system precision standards page covers the underlying datum concepts in detail.
import geopandas as gpd
from pyproj import CRS
import pyproj
# Enable network to auto-download datum shift grids if not cached locally
pyproj.network.set_network_enabled(True)
TARGET_EPSG = 4258 # ETRS89 geographic — required for GML submission
def enforce_crs(gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, list[str], list[str]]:
"""Gate 1: enforce ETRS89 CRS. Returns (transformed_gdf, errors, warnings)."""
errors, warnings = [], []
if gdf.crs is None:
errors.append("No CRS defined. Cannot proceed — confirm source datum with data owner.")
return gdf, errors, warnings
source_epsg = gdf.crs.to_epsg()
target_crs = CRS.from_epsg(TARGET_EPSG)
if gdf.crs.equals(target_crs):
return gdf, errors, warnings # already compliant
try:
gdf = gdf.to_crs(target_crs)
warnings.append(f"Reprojected EPSG:{source_epsg} → EPSG:{TARGET_EPSG} via pyproj (NTv2 if available).")
except Exception as exc:
errors.append(f"CRS transformation failed: {exc}")
return gdf, errors, warnings
Verification: after reprojection, check that all coordinate values fall within plausible ETRS89 bounds — longitude −32° to 45°, latitude 27° to 72° for European datasets. Any point outside this envelope indicates a datum mismatch, not a projection error.
Step 2 — Schema Harmonisation and Codelist Binding
INSPIRE application schemas define mandatory attributes with precise names and controlled vocabularies. The attribute schema mapping approach applies here: build a lookup table that maps local field names to INSPIRE names, cast types, and validate values against official codelist URIs at https://inspire.ec.europa.eu/codelist/.
import pandas as pd
# Example mapping for the AdministrativeUnit theme
FIELD_MAP = {
"local_id": "gml_id", # serialised as gml:id in GML output
"start_date": "beginLifespanVersion",
"end_date": "endLifespanVersion",
"unit_type": "nationalLevel",
}
# Codelist URI lookup for nationalLevel (AdministrativeHierarchyLevel)
NATIONAL_LEVEL_CODELIST = {
"country": "http://inspire.ec.europa.eu/codelist/AdministrativeHierarchyLevel/1stOrder",
"region": "http://inspire.ec.europa.eu/codelist/AdministrativeHierarchyLevel/2ndOrder",
"district": "http://inspire.ec.europa.eu/codelist/AdministrativeHierarchyLevel/3rdOrder",
"municipality": "http://inspire.ec.europa.eu/codelist/AdministrativeHierarchyLevel/4thOrder",
}
def harmonise_schema(gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, list[str], list[str]]:
"""Gate 2: rename columns, synthesise mandatory attributes, bind codelists."""
errors, warnings = [], []
# Rename mapped fields
rename_present = {k: v for k, v in FIELD_MAP.items() if k in gdf.columns}
gdf = gdf.rename(columns=rename_present)
# Synthesise mandatory attributes if absent
if "gml_id" not in gdf.columns:
gdf["gml_id"] = [f"feat_{i}" for i in range(len(gdf))]
warnings.append("gml_id synthesised — replace with authoritative persistent identifiers before submission.")
if "beginLifespanVersion" not in gdf.columns:
gdf["beginLifespanVersion"] = "2026-01-01T00:00:00Z"
warnings.append("beginLifespanVersion defaulted to 2026-01-01T00:00:00Z.")
if "endLifespanVersion" not in gdf.columns:
gdf["endLifespanVersion"] = "9999-12-31T00:00:00Z"
# Bind codelist URIs
if "nationalLevel" in gdf.columns:
unknown_mask = ~gdf["nationalLevel"].isin(NATIONAL_LEVEL_CODELIST)
if unknown_mask.any():
bad_vals = gdf.loc[unknown_mask, "nationalLevel"].unique().tolist()
errors.append(f"Unrecognised nationalLevel values: {bad_vals}. Map to INSPIRE codelist URIs.")
gdf["nationalLevel"] = gdf["nationalLevel"].map(NATIONAL_LEVEL_CODELIST).fillna(
"http://inspire.ec.europa.eu/codelist/VoidReasonValue/Unknown"
)
return gdf, errors, warnings
Verification: print gdf.dtypes and spot-check five rows of the codelist column. Every value must begin with http://inspire.ec.europa.eu/codelist/.
Step 3 — Topology and Geometry Integrity
Each INSPIRE theme defines spatial constraints. Administrative boundaries must satisfy MustNotOverlap; infrastructure networks require MustBeSinglePart; land-cover polygons must satisfy MustNotHaveGaps. Build an R-tree index before pairwise checks to avoid O(n²) performance degradation on large datasets. The OGC topology rules page explains the formal predicate semantics behind these constraints.
from shapely.validation import make_valid
from shapely.strtree import STRtree
def validate_topology(gdf: gpd.GeoDataFrame, theme: str = "administrative") -> tuple[gpd.GeoDataFrame, list[str], list[str]]:
"""Gate 3: geometry validity, duplicate detection, and theme-specific topology."""
errors, warnings = [], []
# Repair invalid geometries first
invalid_mask = ~gdf.geometry.is_valid
if invalid_mask.any():
count = int(invalid_mask.sum())
warnings.append(f"{count} invalid geometries repaired with make_valid().")
gdf = gdf.copy()
gdf.loc[invalid_mask, "geometry"] = gdf.loc[invalid_mask, "geometry"].apply(make_valid)
# Reject empty geometries — they fail every INSPIRE spatial predicate
empty_mask = gdf.geometry.is_empty
if empty_mask.any():
errors.append(f"{int(empty_mask.sum())} empty geometries found. Remove or fill before submission.")
# Duplicate geometry check
dup_mask = gdf.geometry.duplicated(keep=False)
if dup_mask.any():
warnings.append(f"{int(dup_mask.sum())} duplicate geometries — verify against theme overlap rules.")
# MustNotOverlap check for administrative theme using STR-tree
if theme == "administrative":
tree = STRtree(gdf.geometry.values)
overlap_count = 0
for i, geom in enumerate(gdf.geometry):
candidate_idx = tree.query(geom)
for j in candidate_idx:
if j <= i:
continue
other = gdf.geometry.iloc[j]
if geom.overlaps(other):
overlap_count += 1
if overlap_count > 0:
errors.append(f"{overlap_count} overlapping polygon pairs violate MustNotOverlap for administrative boundaries.")
return gdf, errors, warnings
Verification: after make_valid(), re-run gdf.geometry.is_valid.all() — it must return True. The STR-tree overlap check should log zero pairs for compliant data.
Step 4 — Metadata Generation and GML Export
Every INSPIRE dataset submission requires ISO 19115/19139 metadata accompanying the GML. Automate metadata generation using lxml to build the XML structure, then serialise the spatial data with ogr2ogr.
from lxml import etree
from datetime import datetime
import subprocess, pathlib
def generate_metadata_xml(dataset_title: str, output_path: str) -> None:
"""Produce a minimal ISO 19115/19139 metadata document for INSPIRE compliance."""
GMD = "http://www.isotc211.org/2005/gmd"
GCO = "http://www.isotc211.org/2005/gco"
nsmap = {"gmd": GMD, "gco": GCO}
root = etree.Element(f"{{{GMD}}}MD_Metadata", nsmap=nsmap)
# Dataset title
id_info = etree.SubElement(root, f"{{{GMD}}}identificationInfo")
data_id = etree.SubElement(id_info, f"{{{GMD}}}MD_DataIdentification")
citation = etree.SubElement(data_id, f"{{{GMD}}}citation")
ci_cit = etree.SubElement(citation, f"{{{GMD}}}CI_Citation")
title_el = etree.SubElement(ci_cit, f"{{{GMD}}}title")
cs = etree.SubElement(title_el, f"{{{GCO}}}CharacterString")
cs.text = dataset_title
# Metadata date
date_el = etree.SubElement(root, f"{{{GMD}}}dateStamp")
date_val = etree.SubElement(date_el, f"{{{GCO}}}DateTime")
date_val.text = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
tree = etree.ElementTree(root)
tree.write(output_path, pretty_print=True, xml_declaration=True, encoding="UTF-8")
def export_to_gml(input_gpkg: str, output_gml: str) -> None:
"""Serialise a GeoPackage to INSPIRE-compliant GML 3.2.1 via ogr2ogr."""
cmd = [
"ogr2ogr",
"-f", "GML",
"-dsco", "FORMAT=GML3",
"-lco", "GML_ID=YES",
output_gml,
input_gpkg,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"ogr2ogr failed: {result.stderr}")
Verification: validate the output GML against the INSPIRE XSD with xmllint --schema <theme>.xsd output.gml --noout. Zero errors confirms schema compliance.
Interpreting Results
The pipeline collects errors and warnings into a structured report:
{
"status": "PASS",
"errors": [],
"warnings": [
"Reprojected EPSG:27700 → EPSG:4258 via pyproj (NTv2 if available).",
"gml_id synthesised — replace with authoritative persistent identifiers before submission.",
"3 invalid geometries repaired with make_valid()."
]
}
| Report field | Meaning | Action |
|---|---|---|
status: FAIL |
One or more errors block export | Fix all errors; re-run from gate 1 |
status: PASS with warnings |
Dataset exported; quality concerns flagged | Review warnings with data steward; log decisions |
errors[].CRS transformation failed |
Datum grid unavailable or incompatible projection | Enable pyproj network mode or pre-install proj-data package |
errors[].Unrecognised codelist values |
Local values not in INSPIRE vocabulary | Update codelist mapping table; use VoidReasonValue/Unknown as fallback |
errors[].empty geometries |
Features with no geometry | Delete or obtain geometry from source system |
errors[].overlapping polygon pairs |
Administrative boundary conflict | Consult authoritative boundary dataset; re-clip affected polygons |
warnings[].gml_id synthesised |
Identifiers auto-generated | Replace with persistent URIs from your organisation’s identifier scheme |
Persistent codelist failures typically signal upstream data collection gaps — route these to the relevant data steward with a ticket rather than silently mapping to Unknown.
Gotchas and Edge Cases
1. Silent coordinate drift when no NTv2 grid is available. If pyproj cannot locate the appropriate NTv2 grid for a national datum (e.g., BE72 → ETRS89), it falls back to a less accurate 7-parameter Helmert transformation. Residual errors can exceed 1 m in some regions. Always check CRS.from_epsg(source_epsg).to_3d() to confirm a 3D datum shift path exists, and log the transformation authority string from pyproj.Transformer.from_crs(...).to_proj4().
2. gml:id is illegal as a pandas column name. The colon character is not permitted in standard pandas column labels. Store it as gml_id throughout the pipeline and rely on ogr2ogr -lco GML_ID=YES to emit the correct gml:id XML attribute at serialisation time. Do not attempt to rename the column to gml:id — this breaks parquet, GeoPackage, and most spatial databases.
3. make_valid() can change geometry type. A self-intersecting polygon may become a GeometryCollection containing a polygon and a line after repair. INSPIRE themes that require pure polygon geometry will reject these. After applying make_valid(), filter with gdf = gdf[gdf.geometry.geom_type.isin(["Polygon", "MultiPolygon"])] and log discarded features.
4. Aggressive vertex snapping introduces new invalid geometries. Snapping vertices to a 0.001 m tolerance to fix near-misses can create zero-area slivers or cause ring collapse in narrow features. Always re-run is_valid after any snapping operation and treat newly invalid geometries as errors rather than re-applying make_valid() in a loop.
5. Codelist URIs are version-sensitive. INSPIRE codelists are versioned. A URI valid in 2022 may be deprecated in a later INSPIRE Maintenance and Implementation Group release. Pin the codelist version in your mapping table and schedule an annual review against the INSPIRE registry at https://inspire.ec.europa.eu/codelist/.
When to Escalate
This procedure handles single-dataset batch transformations up to roughly 500,000 features on a single machine with 16 GB RAM. Escalate to a more capable approach in these situations:
- Feature count exceeds 500k: the STR-tree pairwise overlap check becomes memory-intensive. Move the topology validation to PostGIS using
ST_Overlapswith a GiST index, or use asynchronous validation workflows with Celery workers partitioned by bounding box. - Multi-theme cross-boundary validation required: e.g., verifying that administrative boundaries align with cadastral parcel boundaries across themes. This requires a joined spatial predicate that is outside the scope of a single-theme pipeline — use a purpose-built rule engine following the patterns in Building Rule Engines with GeoPandas.
- Continuous ingestion rather than batch: if source data is updated more frequently than weekly, the procedural pipeline above becomes a bottleneck. Implement an event-driven architecture with a message queue between the CRS and schema gates.
- Regulatory audit trail required: if your organisation must demonstrate compliance to an external auditor, the JSON report produced here is insufficient. Integrate with an OpenLineage-compatible metadata store and generate a signed provenance record per dataset version.
Related
- Compliance Framework Alignment — translating regulatory standards into executable validation rules
- Coordinate Reference System Precision Standards — ETRS89 datum concepts, NTv2 grids, and acceptable residual error thresholds
- Understanding OGC Topology Rules — formal definitions of MustNotOverlap, MustBeSinglePart, and related spatial predicates
- Audit Scoping for Municipal GIS Assets — scoping compliance validation across a multi-dataset municipal inventory
Back to Compliance Framework Alignment