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.

Four-gate INSPIRE validation pipeline Data flows left to right through four sequential gates: CRS Enforcement, Schema Harmonisation, Topology and Geometry, and Metadata Generation. Failures at any gate route back to the data steward via a JSON error report. Local GIS Dataset Gate 1 CRS Enforcement ETRS89 / NTv2 Gate 2 Schema Harmonisation Codelist URIs Gate 3 Topology & Geometry R-tree / Shapely GML 3.2.1 + Metadata FAIL → JSON error report → 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+, and lxml 4.9+ installed in a dedicated virtual environment. Pin versions in requirements.txt to guarantee reproducible transforms across machines.
  • GDAL 3.6+ on the system path. The ogr2ogr binary is required for GML 3.2.1 serialisation at gate 4. Verify with ogr2ogr --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 the proj-data package 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_Overlaps with 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

Back to Compliance Framework Alignment