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).
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:hasGeometryvoor elk jaar waarin de grens wijzigde (Adorp: 1812 en 1969). De grens van een jaar geldt tot de eerstvolgende grenswijziging. - Opvolging.
gg:absorbedBywijst naar de opvolger,gg:endDategeeft 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.
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:
fclassessluit aan op de NEN 3610-stereotypen (i.p.v. GeoNames feature classes). Een gemeente is een administratieve eenheid ->"A".typeskrijgt 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).geometrygebruikt geenGeometryCollection.
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.
| 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") enfclasses("A") zijn GeoJSONproperties-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. Lijnfclassesdaarbij uit op de NEN 3610-stereotypen (gemeente ->"A").- Type-concept-URI hangt aan
rdfs:seeAlso: LPO kent geen formeel TypeAttestation->concept-predicaat, dusrdfs:seeAlsois 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:inkrijgt een jaar-literal. De ontologie geeftlpo:inals rangetime: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 apartetime: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 jaarCONCATlaat falen en er geen lossehas_start/has_endontstaat (zieconstruct_lpf.2.rq). Voor relaties/links volstaan gewone[ ... ]blank nodes. Omdat de stages in aparte Jena-processen draaien en daarna metriotworden samengevoegd, worden blank-node-labels per invoerbestand gescopet: de_:bNuit 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.
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 ->
when1812 t/m 1969 - geometrie 1969 ->
when1969 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-Timespanalleen eenhas_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:
xsd:gYearis 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 inMIN().- 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.rqmaakt van elkegg:year-geometrie een apartelpo:Settingen 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).
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@contextdie delpo:-termen op de LPF-JSON-sleutels mapt -> JSON, met een aparte WKT -> GeoJSON-stap (of laat de geometrie alsgeowkt-literal staan; LPF staat dat toe, maar dan valideert het bestand niet als pure GeoJSON). De genestelpo:-attestation- en Timespan-structuur sluit hier wel netjes op de LPF-@contextaan, 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
FeatureCollectionsamenstelt. 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.)
- 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 opgg:endDate) engg:originatedFrom-> label "ontstaan uit X" (gescoped opgg:startDate); zie de relatie-when-tabel in §4. Alternatief is een eigenlpo:- 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 viardfs:seeAlsonaar de gg-schema-klasse met label "Gemeente"@nl (zie de TODO inconstruct_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
linksof als basis voor een naam-historie innames[]. - Verrijking. Via dbpedia/wikipedia is een Wikidata-
closeMatchaf te leiden; nuttig voor reconciliatie, maar buiten deze basis-scope.
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 8906convert.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).
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 (sparqlper stage +riotsamenvoegen) ->linkedplaces.ntlinkedplaces.nt- LPO-RDF-uitvoer van de CONSTRUCTs (N-Triples), in..(de QLever-projectroot)
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.