DCAT-AP Spatial Profile Mapping

Translating institutional geospatial catalog records into DCAT-AP Spatial-compliant RDF graphs is a prerequisite for publication to European national open data portals and cross-border DCAT-AP aggregators. The challenge is not format conversion alone — it is semantic alignment: bounding boxes must appear as WKT literals with explicit CRS declarations, licenses must resolve to recognized URI registries, and every mandatory property must survive round-trip harvesting. This page belongs to the Automated Metadata Generation & Schema Mapping pipeline family and covers the full workflow from raw source records through validated, publishable RDF.

Prerequisites

  1. Python 3.10+ with an isolated virtual environment (python -m venv .venv)
  2. rdflib>=6.3 — RDF graph construction and JSON-LD/Turtle serialization
  3. pydantic>=2.0 — structural validation of extracted metadata before graph injection
  4. lxml>=4.9 — ISO 19115 XML and CSW GetRecords response parsing
  5. pyshacl>=0.25 — SHACL shapes validation against DCAT-AP v3 reference profiles
  6. requests>=2.31 — CSW endpoint querying and URI resolution checks
  7. Familiarity with the dcat:, dct:, locn:, and gsp: namespace URIs
  8. Read access to source catalogs: CSW endpoint, ISO 19115 XML exports, or FGDC XML files
  9. Environment variable DCAT_AP_SHACL_PATH pointing to the downloaded DCAT-AP SHACL shapes file

Install the stack:

pip install "rdflib>=6.3" "pydantic>=2.0" "lxml>=4.9" "pyshacl>=0.25" "requests>=2.31"

DCAT-AP Spatial Profile Mapping Pipeline Data-flow diagram showing four stages: source metadata (ISO 19115, FGDC, CSW) flows into field normalization, then geometry and CRS resolution, then RDF graph serialization, then SHACL and Pydantic validation, and finally publication to an open data portal. A failure branch from validation loops back to re-mapping. Source Records ISO 19115 · FGDC · CSW 1. Field Normalization dct:title · dct:license 2. Geometry & CRS Resolution WKT · EPSG:4326 3. RDF Graph Serialization JSON-LD · Turtle SHACL valid? Publish Open Data Portal Log & Re-map fix field errors yes no

Concept & Spec Reference

DCAT-AP (the Data Catalog Vocabulary Application Profile for European data portals) extends W3C DCAT 3 with mandatory and recommended properties specific to public sector data sharing. The Spatial extension adds geometry-level requirements that plain DCAT-AP omits.

Core namespaces

Prefix Namespace URI Role in spatial mapping
dcat http://www.w3.org/ns/dcat# Dataset, Distribution, Catalog, bbox, centroid
dct http://purl.org/dc/terms/ title, description, publisher, license, spatial
locn http://www.w3.org/ns/locn# geometry, Address
gsp http://www.opengis.net/ont/geosparql# wktLiteral, asWKT
dcatap http://data.europa.eu/r5r/ DCAT-AP-specific constraints
xsd http://www.w3.org/2001/XMLSchema# datatype literals

Mandatory properties for dcat:Dataset (DCAT-AP v3)

Property Cardinality Notes
dct:title 1…n One entry per language
dct:description 1…n One entry per language
dcat:distribution 0…n At least one recommended
dct:publisher 0…1 Mandatory in most national profiles
dct:license 0…1 Required at distribution level
dcat:bbox 0…1 Spatial extension: WKT or GeoJSON literal
dcat:centroid 0…1 Spatial extension: point geometry
dct:spatial 0…n Named place URI (GeoNames, Nominatim)
dct:conformsTo 0…n CRS URI, e.g. EPSG:4326

Namespace registry (production pattern)

Establish a deterministic registry at module load time to prevent URI collisions during serialization and SPARQL querying:

from rdflib import Namespace

NAMESPACES = {
    "dcat": Namespace("http://www.w3.org/ns/dcat#"),
    "dct":  Namespace("http://purl.org/dc/terms/"),
    "locn": Namespace("http://www.w3.org/ns/locn#"),
    "gsp":  Namespace("http://www.opengis.net/ont/geosparql#"),
    "dcatap": Namespace("http://data.europa.eu/r5r/"),
    "xsd":  Namespace("http://www.w3.org/2001/XMLSchema#"),
    "foaf": Namespace("http://xmlns.com/foaf/0.1/"),
}

Implementation Walkthrough

Step 1 — Inventory and field normalization

Extract source metadata from your catalog database, XML exports, or OGC CSW endpoints and normalize internal field names to DCAT-AP equivalents. For legacy ISO 19115 records, running them through ISO 19115 metadata template generation first ensures the spatial extent and metadata contact blocks are already in a structured form before DCAT-AP translation begins.

# normalize_fields.py — convert an ISO 19115 parsed dict to DCAT-AP field map
from lxml import etree
from typing import Optional

ISO_NS = {
    "gmd": "http://www.isotc211.org/2005/gmd",
    "gco": "http://www.isotc211.org/2005/gco",
}

def extract_iso19115_fields(xml_path: str) -> dict:
    """Return a normalized field map from an ISO 19115 XML file."""
    tree = etree.parse(xml_path)
    root = tree.getroot()

    def text(xpath: str) -> Optional[str]:
        nodes = root.xpath(xpath, namespaces=ISO_NS)
        return nodes[0].strip() if nodes else None

    return {
        "title":       text(".//gmd:title/gco:CharacterString/text()"),
        "description": text(".//gmd:abstract/gco:CharacterString/text()"),
        "publisher":   text(".//gmd:organisationName/gco:CharacterString/text()"),
        "license_uri": text(".//gmd:accessConstraints/gmd:MD_RestrictionCode/@codeListValue"),
        "west":  text(".//gmd:westBoundLongitude/gco:Decimal/text()"),
        "east":  text(".//gmd:eastBoundLongitude/gco:Decimal/text()"),
        "south": text(".//gmd:southBoundLatitude/gco:Decimal/text()"),
        "north": text(".//gmd:northBoundLatitude/gco:Decimal/text()"),
        "access_url": text(".//gmd:linkage/gmd:URL/text()"),
        "format":     text(".//gmd:distributionFormat/gmd:MD_Format/gmd:name/gco:CharacterString/text()"),
    }

For North American FGDC records, the FGDC to ISO 19115 conversion pipeline produces a compatible intermediate structure. Feed its output directly into the normalization step above rather than building a second FGDC-specific mapper.

Step 2 — Structural validation with Pydantic

Validate the normalized field map before touching the RDF graph. Catching missing titles, malformed URLs, or invalid license identifiers at this stage prevents silent omissions in the final serialization.

from pydantic import BaseModel, HttpUrl, Field, field_validator
from typing import Optional
import re

class SpatialDatasetModel(BaseModel):
    title: str = Field(min_length=3)
    description: str
    publisher: str
    license_uri: str  # validated as URI below
    west: float = Field(ge=-180, le=180)
    east: float = Field(ge=-180, le=180)
    south: float = Field(ge=-90, le=90)
    north: float = Field(ge=-90, le=90)
    access_url: HttpUrl
    format: Optional[str] = None

    @field_validator("license_uri")
    @classmethod
    def license_must_be_uri(cls, v: str) -> str:
        if not re.match(r"^https?://", v):
            raise ValueError(f"license_uri must be an absolute URI, got: {v!r}")
        return v

    @field_validator("east")
    @classmethod
    def east_gt_west(cls, v: float, info) -> float:
        west = info.data.get("west")
        if west is not None and v <= west:
            raise ValueError(f"east ({v}) must be greater than west ({west})")
        return v

Step 3 — Geometry and CRS resolution

DCAT-AP Spatial mandates dcat:bbox as a WKT literal with an explicit gsp:wktLiteral datatype. Convert the four bounding-box coordinates to a closed POLYGON and bind the CRS via dct:conformsTo.

from rdflib import Graph, URIRef, Literal, BNode
from rdflib.namespace import RDF, DCTERMS

def build_bbox_wkt(west: float, south: float, east: float, north: float) -> str:
    """Return a closed WKT POLYGON in WGS84 order (lon lat)."""
    return (
        f"POLYGON(({west} {south}, {east} {south}, "
        f"{east} {north}, {west} {north}, {west} {south}))"
    )

Always reproject to WGS84 (EPSG:4326) before serialization. If your source data uses a projected CRS — OSGB36, ETRS89-LAEA, or a national grid — use pyproj to convert the bounding box coordinates:

from pyproj import Transformer

def reproject_bbox(
    west: float, south: float, east: float, north: float,
    from_crs: str, to_crs: str = "EPSG:4326"
) -> tuple[float, float, float, float]:
    """Reproject a bounding box from from_crs to to_crs."""
    transformer = Transformer.from_crs(from_crs, to_crs, always_xy=True)
    w_lon, s_lat = transformer.transform(west, south)
    e_lon, n_lat = transformer.transform(east, north)
    return w_lon, s_lat, e_lon, n_lat

Step 4 — RDF graph construction and JSON-LD serialization

Bind namespaces first, then attach dataset-level triples, geometry, and distribution. Serialize to JSON-LD for REST API consumers and to Turtle for catalog harvesters in a single pass.

import uuid
from rdflib import Graph, URIRef, Literal, BNode
from rdflib.namespace import RDF, DCTERMS, FOAF, XSD

def build_dcat_ap_graph(
    fields: dict,
    base_uri: str = "https://data.example.gov/datasets/",
) -> Graph:
    """
    Construct a DCAT-AP Spatial-compliant RDF graph from a normalized field dict.
    Returns a populated rdflib.Graph ready for serialization or SHACL validation.
    """
    DCAT  = NAMESPACES["dcat"]
    GSP   = NAMESPACES["gsp"]
    LOCN  = NAMESPACES["locn"]

    g = Graph()
    for prefix, ns in NAMESPACES.items():
        g.bind(prefix, ns)

    dataset_uri = URIRef(f"{base_uri}{uuid.uuid4()}")
    g.add((dataset_uri, RDF.type, DCAT.Dataset))

    # Core Dublin Core properties
    g.add((dataset_uri, DCTERMS.title,       Literal(fields["title"], lang="en")))
    g.add((dataset_uri, DCTERMS.description, Literal(fields["description"], lang="en")))

    # Publisher as a blank-node Organization
    publisher_bn = BNode()
    g.add((publisher_bn, RDF.type, FOAF.Organization))
    g.add((publisher_bn, FOAF.name, Literal(fields["publisher"])))
    g.add((dataset_uri, DCTERMS.publisher, publisher_bn))

    # License
    g.add((dataset_uri, DCTERMS.license, URIRef(fields["license_uri"])))

    # CRS conformance
    g.add((dataset_uri, DCTERMS.conformsTo, URIRef("http://www.opengis.net/def/crs/EPSG/0/4326")))

    # Bounding box as WKT literal
    bbox_wkt = build_bbox_wkt(
        float(fields["west"]), float(fields["south"]),
        float(fields["east"]), float(fields["north"])
    )
    g.add((
        dataset_uri,
        DCAT.bbox,
        Literal(bbox_wkt, datatype=GSP.wktLiteral),
    ))

    # Distribution
    dist_uri = URIRef(f"{base_uri}{uuid.uuid4()}/dist")
    g.add((dist_uri, RDF.type, DCAT.Distribution))
    g.add((dist_uri, DCAT.accessURL, URIRef(str(fields["access_url"]))))
    if fields.get("format"):
        g.add((dist_uri, DCTERMS.format, Literal(fields["format"])))
    g.add((dataset_uri, DCAT.distribution, dist_uri))

    return g


def serialize_graph(g: Graph, base_path: str) -> None:
    """Write Turtle and JSON-LD outputs side-by-side."""
    g.serialize(destination=f"{base_path}.ttl", format="turtle")
    g.serialize(destination=f"{base_path}.jsonld", format="json-ld", indent=2)

Validation & CI Integration

SHACL validation with pyshacl

The European Commission publishes reference SHACL shapes for DCAT-AP v3. Download the shapes file once and point DCAT_AP_SHACL_PATH at it. Run validation after every graph build — not only before publishing.

import os
from pyshacl import validate as shacl_validate
from rdflib import Graph

def validate_dcat_ap(data_graph: Graph) -> tuple[bool, str]:
    """
    Validate data_graph against the DCAT-AP SHACL shapes.
    Returns (conforms: bool, report_text: str).
    """
    shapes_path = os.environ["DCAT_AP_SHACL_PATH"]
    shapes_graph = Graph().parse(shapes_path, format="turtle")

    conforms, report_graph, report_text = shacl_validate(
        data_graph,
        shacl_graph=shapes_graph,
        inference="rdfs",
        abort_on_first=False,
    )
    return conforms, report_text

Pre-commit hook

Add a lightweight validation script to .pre-commit-config.yaml so that mapping logic changes are checked before they reach the main branch:

# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: dcat-ap-shacl-check
        name: DCAT-AP SHACL validation
        language: python
        entry: python ci/validate_dcat_ap_sample.py
        files: "^mapping/.*\\.py$"
        additional_dependencies:
          - "rdflib>=6.3"
          - "pyshacl>=0.25"

ci/validate_dcat_ap_sample.py should parse a fixture JSON-LD file from tests/fixtures/, build the graph, and call validate_dcat_ap(), exiting non-zero on failure.

Pytest assertion pattern

import pytest
from rdflib import Graph
from mapping.dcat_ap_builder import build_dcat_ap_graph
from mapping.validation import validate_dcat_ap

VALID_FIELDS = {
    "title": "Administrative Boundaries 2024",
    "description": "Municipal boundaries for the metropolitan area.",
    "publisher": "City GIS Unit",
    "license_uri": "https://creativecommons.org/licenses/by/4.0/",
    "west": "-1.5", "east": "1.5", "south": "48.5", "north": "51.5",
    "access_url": "https://data.example.gov/layers/boundaries.gpkg",
    "format": "GeoPackage",
}

def test_valid_graph_passes_shacl():
    g = build_dcat_ap_graph(VALID_FIELDS)
    conforms, report = validate_dcat_ap(g)
    assert conforms, f"SHACL violations:\n{report}"

def test_missing_title_raises():
    bad = {**VALID_FIELDS, "title": ""}
    with pytest.raises(Exception):
        build_dcat_ap_graph(bad)

Derivative & Lineage Management

When source datasets are transformed — reprojected to a national grid, clipped to an administrative boundary, joined with tabular attributes, or rasterized — the DCAT-AP graph must reflect those operations in its lineage metadata. Failing to update the dcat:bbox, dct:spatial, and dct:conformsTo triples after a CRS change is one of the most common compliance failures in automated pipelines.

Key obligations after transformation:

  • Reprojection: Update dcat:bbox to WGS84 coordinates of the reprojected extent. Retain the native CRS declaration under dct:conformsTo. Add a dct:source triple pointing to the pre-reprojection dataset URI.
  • Clip operations: Recompute the bounding box from the clipped geometry. The original dcat:bbox from the source record is no longer valid and must not be inherited.
  • Format conversion: Create a new dcat:Distribution node with updated dct:format and dcat:mediaType. Link it to the parent dataset via dcat:distribution. Do not modify the existing distribution node in place.
  • Join / attribute enrichment: Add a dct:relation triple linking the enriched dataset to its attribute source. If the join adds licensing obligations (e.g., merging CC-BY and ODbL layers), re-evaluate dct:license at the dataset level to reflect the most restrictive applicable term.

For lineage tracking at scale, consider the metadata schema validation and linting workflows, which provide automated diff-checking between pre- and post-transformation graphs to catch silent property deletions.

Tracking lineage in the RDF graph

from rdflib import Graph, URIRef, Literal
from rdflib.namespace import DCTERMS, PROV

def attach_lineage(
    g: Graph,
    derived_uri: URIRef,
    source_uri: URIRef,
    activity_label: str,
) -> None:
    """
    Attach minimal PROV-O lineage triples to the derived dataset.
    activity_label: human-readable description, e.g. 'Reprojected to EPSG:4326'.
    """
    activity = URIRef(str(derived_uri) + "/provenance/activity")
    g.add((derived_uri, PROV.wasDerivedFrom, source_uri))
    g.add((derived_uri, PROV.wasGeneratedBy, activity))
    g.add((activity, DCTERMS.description, Literal(activity_label, lang="en")))

Pitfalls & Resolution Table

Pitfall Root Cause Resolution Strategy
dcat:bbox literal rejected by harvester Missing gsp:wktLiteral datatype URI on the Literal Always pass datatype=NAMESPACES["gsp"].wktLiteral when constructing the Literal; never omit the datatype argument
Harvest failure: unresolvable dct:license URI Legacy records store FGDC access constraint codes like "otherRestrictions" rather than URIs Map constraint codes to recognized EU Open Data Portal or Creative Commons URIs in the normalization step before graph construction
SHACL failure: dct:title missing language tag Literal("My Title") without lang arg fails cardinality check in strict national profiles Always pass lang="en" (or appropriate BCP-47 tag); use multilingual Literals for multi-lingual portals
Bounding box coordinates reversed Source records in lat/lon order; WKT expects lon/lat Normalize coordinate order during the geometry resolution step; add an assertion that west < east and south < north
Duplicate dataset URIs on repeated pipeline runs UUID-based URIs regenerated on each run; downstream triple stores accumulate duplicates Derive dataset URIs deterministically from a stable identifier (e.g., hashlib.sha256(catalog_id + version)) rather than uuid.uuid4()
JSON-LD @context not resolvable offline Default rdflib JSON-LD serializer emits remote context URIs that harvesters try to dereference Embed a local @context mapping in the serialized output or configure the harvester to cache context documents
dcat:centroid absent but required by national profile Centroid property omitted during graph construction Compute centroid from bbox midpoint: lon = (west + east) / 2, lat = (south + north) / 2; serialize as WKT POINT
Silent property loss after clip/reproject Transformation scripts inherit source graph and overwrite only changed triples; stale triples persist Re-validate the full graph after every transformation step; compare triple count before and after as a smoke test