Skip to content

Repository files navigation

Gemeentegeschiedenis.nl naar Linked Places (Nederlands profiel "geo op tijd")

Aanpak om de data van gemeentegeschiedenis.nl te harvesten en, bij voorkeur met SPARQL CONSTRUCTs, om te zetten naar het Linked Places Format (LPF), het uitwisselingsformaat waar het Geonovum-profiel geo op tijd op aansluit.

De pijplijn in een zin: harvest -> SPARQL CONSTRUCT met Apache Jena (CLI) naar LPO-RDF -> samenvoegen met riot -> QLever-index/endpoint -> validatie. De CONSTRUCTs produceren een RDF-graaf in de Linked Pasts Ontology (LPO); de meegeleverde linkedplaces.nt is die feitelijke uitvoer (N-Triples), die met de Qleverfile als SPARQL-endpoint wordt geserveerd. De omzetting draait server-loos via Jena's command-line tools (convert.sh) -- geen draaiende store nodig tijdens het transformeren. De uitwisseling gebeurt als LPO-RDF via het QLever-endpoint; een GeoJSON-serialisatie kan als optionele vervolgstap (zie §5). De mapping is end-to-end gedraaid op de volledige dump (1692 gemeenten; o.a. gecontroleerd op Adorp, Aa en Hunze, Aagtekerke, Westkerke).


1. Bronanalyse

Elke gemeente, provincie, Amsterdamse code en CBS-code heeft een eigen URI die via content-negotiation HTML, JSON of RDF teruggeeft (of expliciet via json/ of rdfxml/ in het pad). Daarnaast is er per gemeente GeoJSON in de JSON-variant, een WFS, en een CSV per provincie.

Belangrijk gevolg voor de werkwijze: de site zelf biedt geen SPARQL-endpoint of dump, maar de data staat wel als geheel klaar op DataLegend/TriplyDB (nlgis/gemeentegeschiedenis) - dezelfde dataset die de site voedt, met hetzelfde gg-schema-vocabulaire. Het harvesten is daarmee een enkele download van de N-Triples-graph (zie het kopje Uitvoeren), die je in een triple store laadt om de CONSTRUCTs op te draaien. Dat vervangt het per-gemeente crawlen volledig. (Er is ook een SPARQL-endpoint op DataLegend, waarop je de CONSTRUCTs desgewenst server-side kunt uitvoeren.)

Het bronvocabulaire (gg: http://www.gemeentegeschiedenis.nl/gg-schema#) ziet er per gemeente zo uit (verkort, Adorp):

<rdf:Description rdf:about=".../gemeentenaam/Adorp">
  <rdf:type rdf:resource=".../gg-schema#Municipality"/>
  <skos:prefLabel xml:lang="nl">Adorp</skos:prefLabel>
  <gg:inProvince    rdf:resource=".../provincie/Groningen"/>
  <gg:inDepartement rdf:resource=".../departement/We"/>
  <gg:cbsCode       rdf:resource=".../cbscode/0001"/>
  <gg:amsterdamCode rdf:resource=".../amco/10996"/>
  <gg:absorbedBy    rdf:resource=".../gemeentenaam/Winsum"/>
  <gg:endDate rdf:datatype="xsd:date">1990-01-01</gg:endDate>
  <geo:hasGeometry>
    <rdf:Description>
      <gg:year rdf:datatype="xsd:gYear">1812</gg:year>
      <geo:asWKT rdf:datatype="geo:wktLiteral">MULTIPOLYGON(((6.56 53.31, ...)))</geo:asWKT>
    </rdf:Description>
  </geo:hasGeometry>
  <!-- nog een geo:hasGeometry voor 1969 -->
  <gg:resourceWikipedia rdf:resource="https://nl.wikipedia.org/wiki/Adorp"/>
  <gg:resourceDbpedia   rdf:resource="http://nl.dbpedia.org/resource/Adorp"/>
</rdf:Description>

Twee dingen zijn cruciaal voor het tijdsaspect:

  • Geometrie per grensjaar. Er is een geo:hasGeometry voor elk jaar waarin de grens wijzigde (Adorp: 1812 en 1969). De grens van een jaar geldt tot de eerstvolgende grenswijziging.
  • Opvolging. gg:absorbedBy wijst naar de opvolger, gg:endDate geeft de opheffing. Samen vormen ze de successie-keten van gemeenten.

Provincies lijsten hun leden via skos:narrower (en gg:municipalityIn); in de dump-graph staan diezelfde relaties, handig om gemeenten per provincie te selecteren of te tellen.


2. Doelmodel: Nederlands Profiel voor Linked Places (NL-LP)

Het doel is het Nederlands Profiel voor Linked Places (NL-LP) (Geonovum "geo op tijd"), dat voortbouwt op de Linked Pasts Ontology v1.1 (LinkedPasts/linked-pasts-ontology) en is ontworpen rondom JSON-LD, waardoor het zowel valide RDF (Turtle/N-Triples, etc.) als GeoJSON oplevert. De uitwisseling in deze pijplijn is de LPO-RDF.

LPF is een GeoJSON-LD FeatureCollection. Elke Feature heeft: @id, properties (title verplicht, ccodes, en sinds v1.3 verplicht fclasses), when, names, types, geometry, relations, links, descriptions, depictions. De elementen names, types, geometry en relations kunnen elk temporeel worden gescoped met een when { timespans:[{start:{in}, end:{in}}] }.

Wat het NL-profiel concreet voorschrijft (geverifieerd tegen de profielbron, Geonovum/geooptijd), bovenop LPF/LPO:

  1. fclasses sluit aan op de NEN 3610-stereotypen (i.p.v. GeoNames feature classes). Een gemeente is een administratieve eenheid -> "A".
  2. types krijgt een Nederlandse type-ontologie op basis van Getty AAT + INSPIRE. Die ontologie wordt nog gemaakt; tot dan verwijst het type voorlopig naar de gg-schema-klasse met label "Gemeente" (zie de TODO in §6).
  3. geometry gebruikt geen GeometryCollection.

Let op: de profieltekst is nog een vroege draft. CRS, een verplicht perioden-/tijd-vocabulaire voor when, successie-relatietypen, URI-conventies en SHACL/conformance zijn er nog niet ingevuld (TO DO). Voor die onderdelen volgen we de LPO-default en verzinnen we geen eigen regels; herijk ze zodra de profieltekst is aangevuld.


3. De mapping (gg -> LPF)

Bron (gg-schema) LPF Opmerking
resource-URI .../gemeentenaam/X @id stabiele bron-URI als feature-id
skos:prefLabel / gg:name (nl) properties.title + names[].toponym taal nl
impliciet Nederland properties.ccodes = ["NL"] GeoJSON-only veld
rdf:type gg:Municipality properties.fclasses = ["A"] + types[] "A" = administratieve eenheid (NEN 3610); types[] -> AAT/INSPIRE (TODO)
geo:hasGeometry (gg:year, geo:asWKT) per grensjaar een losse geometry met eigen when (geen GeometryCollection) WKT -> GeoJSON; temporele koppeling, zie 4
gg:startDate (anders vroegste gg:year) ... gg:endDate feature-niveau when.timespans bestaansperiode; expliciete startDate gaat voor
gg:inProvince relations[] type gvp:broaderPartitive bestuurlijke ouder
gg:inDepartement relations[] type gvp:broaderPartitive Franse periode (~1811-1813)
gg:absorbedBy relations[] type gvp:tgn3000_related_to opvolging ("opgegaan in")
gg:originatedFrom relations[] type gvp:tgn3000_related_to voorganger ("ontstaan uit")
gg:resourceWikipedia links[] type foaf:primaryTopicOf
gg:resourceDbpedia links[] type skos:closeMatch
gg:resourceGeonames links[] type skos:closeMatch externe gazetteer
gg:cbsCode, gg:amsterdamCode links[] type rdfs:seeAlso concordanties / interne identifiers

De semantische mapping zit in vier losse stage-bestanden construct_lpf.1.rq .. construct_lpf.4.rq (identiteit + naam + type + bestaans-when; geometrieen; relaties; links). Elk bestand is standalone: het draagt zijn eigen PREFIX-set, zodat het los met Jena's sparql gedraaid kan worden. Ze produceren RDF in de Linked Pasts Ontology als doelvocabulaire:

lpo:       http://linkedpasts.org/ontology#
geojson:   https://purl.org/geojson/vocab#
geojson-t: https://github.com/kgeographer/geojson-t#

(plus de standaardtermen rdfs:label en geo:asWKT). Let op: de namespace is http://linkedpasts.org/ontology# zoals de ontologie zelf hem declareert -- niet de GitHub blob-URL van lpo_latest.ttl, want die wijst naar de HTML-pagina en levert geen dereferenceerbare term-IRI's.

LPO is geen platte sleutel-op-sleutel-mapping maar een gestructureerd model: namen, typen, relaties en links zijn attestation-knopen (lpo:name_attestation -> lpo:NameAttestation, idem type_, rel_, link_), en tijd loopt via lpo:when -> lpo:Timespan -> lpo:has_start/lpo:has_end -> lpo:in. De concrete predicaat-mapping:

gg-bron LPO-RDF
resource-URI ?feature a geojson:Feature (URI herschreven naar https://...)
skos:prefLabel rdfs:label + lpo:name_attestation [ a lpo:NameAttestation ; lpo:toponym ] (taal @nl)
rdf:type gg:Municipality lpo:type_attestation [ a lpo:TypeAttestation ; rdfs:seeAlso <gg-schema#Municipality> ; rdfs:label "Gemeente"@nl ] (TODO: AAT/INSPIRE-IRI)
bestaansperiode (gg:startDate of vroegste jaar ... gg:endDate) lpo:when [ a lpo:Timespan ; lpo:has_start [ lpo:in ?startjaar ] ; lpo:has_end [ lpo:in ?eindjaar ] ]
geo:hasGeometry per grensjaar een geojson-t:geometry [ a lpo:Setting ; geo:asWKT ?wkt ; lpo:when [...] ] (losse Settings, geen GeometryCollection)
gg:inProvince / inDepartement / absorbedBy / originatedFrom lpo:rel_attestation [ a lpo:RelAttestation ; lpo:relation_type <IRI> ; lpo:relation_to <IRI> ; rdfs:label ; lpo:when [...] ] (when per relatietype, zie 4; provincie zonder when)
wikipedia / dbpedia / geonames / cbs / amsterdamCode lpo:link_attestation [ a lpo:LinkAttestation ; lpo:relation_type <IRI> ; lpo:relation_to <IRI> ]

lpo:relation_type en lpo:relation_to zijn owl:ObjectProperty: de ontologie schrijft voor dat de waarde een IRI is (een GVP-relatietype resp. een gazetteer-URL), geen string-literal. De CONSTRUCTs leveren daarom IRI's: gvp:broaderPartitive, gvp:tgn3000_related_to, foaf:primaryTopicOf, skos:closeMatch, rdfs:seeAlso.

Bewuste afwijkingen:

  • ccodes ("NL") en fclasses ("A") zijn GeoJSON properties-velden zonder LPO-equivalent (bevestigd: ze staan niet in de ontologie); ze staan niet in de RDF en worden als constanten toegevoegd bij een eventuele GeoJSON-serialisatie. Lijn fclasses daarbij uit op de NEN 3610-stereotypen (gemeente -> "A").
  • Type-concept-URI hangt aan rdfs:seeAlso: LPO kent geen formeel TypeAttestation->concept-predicaat, dus rdfs:seeAlso is hier de pragmatische keuze. Het verwijst nu naar de gg-schema-klasse; vervang dit door een Getty AAT/INSPIRE-IRI zodra de Nederlandse type-ontologie is gepubliceerd (§6).
  • lpo:in krijgt een jaar-literal. De ontologie geeft lpo:in als range time:DateTimeDescription (een resource), maar wij zetten er het jaar als literal in (bv. lpo:in "1812"), conform de LPF-JSON-praktijk ("in":"1812"). Een strikt OWL-Time-model zou hier een aparte time:DateTimeDescription-knoop willen; herzie dit als een afnemer dat eist.

Volledigheid -- bewust niet gemapte bronvelden. Alle inhoudelijke gg-velden worden geconsumeerd; de resterende predicaten zijn inverses of duplicaten en worden bewust overgeslagen: gg:absorbed / gg:originated / gg:municipalityIn (inverses van absorbedBy / originatedFrom / inProvince), skos:broader / skos:narrower (duplicaat van inProvince / inDepartement), gg:name / rdfs:label (duplicaat van skos:prefLabel), en gg:identifies (staat op de AmsterdamCode-objecten, niet op de gemeente).

Implementatienoot (blank nodes): gebruik voor de tijd-gescopete knopen benoemde blank nodes via BNODE(CONCAT(...)) met het jaar in het argument, zodat een ontbrekend jaar CONCAT laat falen en er geen losse has_start/has_end ontstaat (zie construct_lpf.2.rq). Voor relaties/links volstaan gewone [ ... ] blank nodes. Omdat de stages in aparte Jena-processen draaien en daarna met riot worden samengevoegd, worden blank-node-labels per invoerbestand gescopet: de _:bN uit verschillende stages botsen niet, dus een Timespan uit stage 1 en een RelAttestation uit stage 3 blijven aparte knopen.

Waarom vier losse queries en niet een? Twee redenen. (1) Zodra je meerwaardige paden (geometrieen x relaties x links) in een WHERE combineert, ontstaat een cartesisch product en krijg je dubbele blank nodes in de uitvoer. Aparte CONSTRUCTs voorkomen dat -- getest: gecombineerd leverde het dubbele relatie- en linknodes op, gesplitst exact 1 feature, 3 relaties, 4 links, 2 geometrieen. (2) Jena's sparql voert een query per aanroep uit, dus losse stage-bestanden zijn ook praktisch nodig; convert.sh draait ze stuk voor stuk en plakt de grafen met riot samen.


4. Temporele modellering (de kern van de omzetting)

Bestaans-when: begin = jaar uit gg:startDate indien aanwezig, anders het vroegste grensjaar; eind = jaar uit gg:endDate. (construct_lpf.1.rq gebruikt COALESCE(?startDateYear, ?minYear).)

Geometrie-when: sorteer de grensjaren oplopend; elke geometrie geldt van haar jaar tot het eerstvolgende grensjaar, en de laatste tot de opheffing. Voor Adorp:

  • geometrie 1812 -> when 1812 t/m 1969
  • geometrie 1969 -> when 1969 t/m 1990 (endDate)

Relatie-when: ook relaties worden temporeel gescoped, want een bestuurlijke band of successie geldt maar in een bepaalde periode. construct_lpf.3.rq hangt per relatietype een lpo:when aan de lpo:RelAttestation (zelfde Timespan -> has_start/has_end -> in-structuur als hierboven):

Relatie (?kind) bron-veld when reden
inProvince (prov) geen when persistente bestuurlijke ouder; geen zinvolle periode
inDepartement (dep) vast 1811 t/m 1813 duur van de Franse departementale indeling
absorbedBy (abs) gg:endDate opheffingsjaar (start = eind) "opgegaan in X" gebeurt op de opheffingsdatum
originatedFrom (org) gg:startDate oprichtingsjaar (start = eind) "ontstaan uit X" gebeurt op de oprichtingsdatum

De successie-relaties krijgen een eenjarige span (has_start = has_end = gebeurtenisjaar): de fusie/opheffing is een moment, geen interval. De provincie-relatie blijft bewust ongescoped. Net als bij de bestaans-when zit het jaar in het BNODE-argument, dus bij een ontbrekende startDate/endDate valt de hele Timespan weg in plaats van een lege knoop achter te laten — de relatie zelf blijft dan gewoon staan, alleen zonder when.

Partiële grenzen zijn toegestaan en komen voor: heeft de bron alleen een gg:endDate (en geen begin), dan krijgt de bestaans-Timespan alleen een has_end (open begin). Dat is een geldige, betekenisvolle span — geen lege knoop. In de dataset zijn dat bijv. Westkerke en Oosterwolde_Fr (alleen einde 1816).

Twee valkuilen die ik tijdens het testen ben tegengekomen en die in de queries zijn opgelost:

  1. xsd:gYear is in veel SPARQL-engines niet te ordenen. FILTER(?y2 > ?y) op gYear-literals gaf nul resultaten in rdflib, waardoor de eerstvolgende grens niet werd gevonden en alle geometrieen verkeerd op de opheffingsdatum eindigden. Oplossing: cast naar integer, xsd:integer(STR(?y)). Doe dat ook in MIN().
  2. Correleer niet op de blank node van de geometrie. Blank-node-identiteit over een subquery-grens is engine-afhankelijk en breekt. Correleer op (gemeente, jaar).

Verder, eerlijk benoemd:

  • De ondergrens 1812 is in de bron vaak het eerste vastleggingsjaar (de Franse herinrichting), niet noodzakelijk de historische stichtingsdatum. De DataLegend-querybeschrijving bevestigt dit: ontbreekt de begindatum, dan wordt het jaar van de oudst bekende geometrie gebruikt.
  • De departement-relatie scope ik bij benadering op 1811-1813; verfijn dit op basis van de bronkennis als dat nodig is.
  • Geometrie-granulariteit van de dump. Per gemeente geeft de dump een geometrie per grenswijziging (Adorp: 2; gemiddeld ~1,9 in deze dump). De NLGis-shapefiles erachter leggen de grens per kalenderjaar 1812-1997 vast, dus een fijnere dump kan veel meer geometrieen per gemeente bevatten. construct_lpf.2.rq maakt van elke gg:year-geometrie een aparte lpo:Setting en klapt opeenvolgende identieke grenzen niet in tot een interval. Voor deze dump is dat niet nodig; bij een per-kalenderjaar-dump is het samenvoegen van gelijke opeenvolgende grenzen een open verbeterpunt (dat deed de oude Python-serializer wel; in de huidige puur-SPARQL-pijplijn is het nog niet geïmplementeerd).

5. Van CONSTRUCT naar geldige LPF GeoJSON (optionele vervolgstap)

De actieve pijplijn stopt bij de LPO-RDF (linkedplaces.nt), die als SPARQL-endpoint via QLever wordt geserveerd. Wie alsnog een GeoJSON-bestand wil, kan dat als losse vervolgstap doen -- onderstaande routes blijven daarvoor geldig.

SPARQL CONSTRUCT levert een RDF-graaf, geen GeoJSON. Drie dingen kan CONSTRUCT (of JSON-LD-framing) niet native: de geneste coordinates-arrays van GeoJSON, de FeatureCollection-envelop, en de WKT -> GeoJSON-conversie. Daar zijn twee routes voor:

  • Route A (puur declaratief): CONSTRUCT -> LPO-RDF (zoals linkedplaces.nt) -> JSON-LD-frame met een @context die de lpo:-termen op de LPF-JSON-sleutels mapt -> JSON, met een aparte WKT -> GeoJSON-stap (of laat de geometrie als geowkt-literal staan; LPF staat dat toe, maar dan valideert het bestand niet als pure GeoJSON). De geneste lpo:-attestation- en Timespan-structuur sluit hier wel netjes op de LPF-@context aan, maar het framen van die diepe temporele knopen is precies wat Route B vermijdt.
  • Route B: een dunne, SPARQL-gedreven serializer die per gemeente de attributen via SPARQL (bijv. tegen het QLever-endpoint) ophaalt, de temporele koppeling en de WKT -> GeoJSON-conversie (shapely) doet, en de FeatureCollection samenstelt. Robuuster dan framing van diep geneste temporele structuren.

CRS: LPF/GeoJSON vereist CRS84 (lengte-, breedtegraad in WGS84). De bron levert de WKT al in die volgorde en dat datum, dus er is geen herprojectie nodig. (Was de bron in RD / EPSG:28992 geweest, dan wel.)


6. Open keuzes en aandachtspunten

  • Opvolging. LPF en de Getty Vocabulary Ontology kennen geen schone "opgevolgd door"-relatie. Ik gebruik nu het generieke gvp:tgn3000_related_to (als IRI), in beide richtingen: gg:absorbedBy -> label "opgegaan in X" (gescoped op gg:endDate) en gg:originatedFrom -> label "ontstaan uit X" (gescoped op gg:startDate); zie de relatie-when-tabel in §4. Alternatief is een eigen lpo:- of SKOS-relatietype; stem dit af op wat de afnemer (bijv. World Historical Gazetteer) ondersteunt.
  • Place type / AAT (NL-LP TODO). Het NL-profiel schrijft voor dat types[] een Getty AAT/INSPIRE-identifier krijgt, maar de Nederlandse type-ontologie wordt nog gemaakt. Tot dan verwijst de TypeAttestation via rdfs:seeAlso naar de gg-schema-klasse met label "Gemeente"@nl (zie de TODO in construct_lpf.1.rq). Vervang dit door de AAT/INSPIRE-IRI zodra die ontologie gepubliceerd is; voor WHG-ingestie kun je mappen op een AAT-concept uit de WHG-lijst.
  • Meerdere namen / continuiteit. De Amsterdamse code wordt vaak hergebruikt voor opeenvolgende gelijknamige gemeenten; overweeg die als extra links of als basis voor een naam-historie in names[].
  • Verrijking. Via dbpedia/wikipedia is een Wikidata-closeMatch af te leiden; nuttig voor reconciliatie, maar buiten deze basis-scope.

7. Uitvoeren

Benodigdheden: Apache Jena (de CLI-tools sparql en riot, hier in /opt/jena/bin) en QLever voor het serveren.

# 1. HARVEST: download de volledige graph als N-Triples (vervangt het crawlen)
bash get_dump.sh                         # -> gemeentegeschiedenis.nt

# 2. TRANSFORMEER naar LPO-RDF met Jena (server-loos): elke stage in een eigen
#    Jena-proces, daarna met riot samengevoegd tot botsingsvrije N-Triples.
bash convert.sh                          # -> ../linkedplaces.nt (LPO-RDF)

# 3. SERVEER als SPARQL-endpoint met QLever (gebruikt de Qleverfile)
qlever index && qlever start             # indexeert linkedplaces.nt, poort 8906

convert.sh doet zelf de validatie-telling (riot --count) en rapporteert het aantal geschreven triples.

Schaal: voor deze dataset is Jena's --data (4x in-memory parsen) prima. Voor de volledige landelijke dump is een TDB2-store sneller: laad gemeentegeschiedenis.nt eenmalig met tdb2.tdbloader --loc=DB gemeentegeschiedenis.nt en draai de stages met sparql --loc=DB --query=... (zie de noot bovenin convert.sh). Alternatieve stores (Oxigraph, QLever) kunnen dezelfde vier CONSTRUCTs draaien. De allerschoonste variant van "puur SPARQL CONSTRUCT": draai de queries rechtstreeks op het DataLegend-endpoint https://druid.datalegend.net/nlgis/gemeentegeschiedenis/sparql en plak de resultaatgrafen samen.

Validatie: convert.sh parst de uitvoer al opnieuw met riot --count (faalt bij ongeldige N-Triples). Een snelle sanity-check op de LPO-RDF (linkedplaces.nt): geen losse (dangling) lpo:has_start/lpo:has_end zonder lpo:in, en elke geojson:Feature heeft een lpo:when -- te draaien als SPARQL ASK/SELECT op het QLever-endpoint. Optioneel een SHACL-shape op de LPO-RDF. Serialiseer je alsnog naar GeoJSON (§5), controleer dan dat elke feature title, fclasses en minstens een when heeft, dat relationType-waarden dereferenceerbaar zijn, en dat de GeoJSON klopt (bijv. met geojsonhint).


Bestanden

  • get_dump.sh - download + uitpakken van de DataLegend N-Triples-graph (harvest)
  • construct_lpf.1.rq .. construct_lpf.4.rq - de vier standalone SPARQL CONSTRUCTs naar LPO-RDF (de declaratieve mapping)
  • convert.sh - server-loze conversiepijplijn met Apache Jena (sparql per stage + riot samenvoegen) -> linkedplaces.nt
  • linkedplaces.nt - LPO-RDF-uitvoer van de CONSTRUCTs (N-Triples), in .. (de QLever-projectroot)

Live bekijken

De geconverteerde data is beschikbaar via het SPARQL-endpoint https://qlever.coret.org/gemeentegeschiedenis-lp die eenvoudig via Yasgui is te bevragen.

Bekijk als voorbeeld de gemeente Gouda via LDview.

About

Pijplijn voor conversie Gemeentegeschiedenis RDF naar Nederlands profiel voor Linked Places (NL-LP)

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages