The RHT knowledge graph models every building in a city and, for each
scenario, which buildings are flooded and how deeply. It is an RDF graph
served by an Oxigraph triplestore and
queried with SPARQL. The schema lives in ontology/rhdt.ttl; the data is built
from the same FIAT outputs the map already uses.
Why a graph¶
The per-building flood result for every scenario is already computed by Delft-FIAT
and stored in each scenario’s FIAT/spatial.gpkg. The graph makes those facts
queryable as relationships — “which buildings are flooded in scenario X (above
depth d)?” becomes a single SPARQL query instead of a per-file GIS join.
Data model¶
Extends the existing ontology (ontology/rhdt.ttl). New terms:
| Term | Kind | Meaning |
|---|---|---|
rht:Building | class (geo:Feature) | A building / exposure asset, keyed by the stable FIAT object_id. |
rht:FloodImpact | class (sosa:Observation) | One building’s flood result under one scenario. |
rht:onBuilding | FloodImpact → Building | The building an impact describes. |
rht:inScenario | FloodImpact → RasterScenario | The scenario the impact was computed under. |
rht:hasFloodImpact | Building → FloodImpact | Inverse of onBuilding. |
rht:inundationDepth | FloodImpact → xsd:float | Water depth at the building (m). |
rht:damageTotal, rht:totalDamage | FloodImpact → xsd:float | FIAT damage values. |
rht:objectId | Building → xsd:integer | Stable FIAT id. |
geo:asWKT | Building → geo:wktLiteral | Footprint geometry (WGS84). |
Scenarios reuse the existing rht:RasterScenario class (one per scenario folder).
Flooded is decided at query time. A FloodImpact is created for every building
with a non-null inun_depth (i.e. every wet building); the raw depth is stored,
so a query picks the threshold via FILTER(?depth > d). A building with no impact
in a scenario was dry.
IRIs are namespaced by city to avoid object_id collisions between cities:
inst:building_<city>_<objectId>
inst:scenario_<city>_<scenarioFolder>
inst:flood_<city>_<scenarioFolder>_<objectId>Building the graph¶
build_knowledge_graph reads building footprints and per-scenario FIAT results,
emits N-Triples, and (with --load) pushes them into Oxigraph.
# Inside the api container. Wipe the store, then load every scenario found
# under $RASTER_ROOT (geometry derived from each scenario's FIAT/spatial.gpkg):
python manage.py build_knowledge_graph --load --replace
# Just one scenario:
python manage.py build_knowledge_graph --city rotterdam \
--scenario 1_base_scenario --load --replace
# Richer Building nodes (footprints + static attributes) from the FIAT model.
# Requires the WP4 exposure files to be reachable in the container:
python manage.py build_knowledge_graph --load --replace \
--buildings /exposure/buildings.gpkg --exposure /exposure/exposure.csvKey options: --out <file.nt> writes triples to disk (use instead of, or with,
--load); --scenario and --city are repeatable filters; --id-field overrides
the id column name (default object_id).
Geometry is read with
ogr2ogr(already required by the FIAT GeoJSON view); flood attributes are read straight from the GeoPackage SQLite tables. Triples are streamed, so ~200k buildings do not need to fit in memory.
If --buildings is omitted the command derives building footprints from a
scenario’s FIAT/spatial.gpkg, so it runs against only the mounted raster root.
Provide --buildings/--exposure to also attach footprint attributes
(primary_object_type, max_damage_total, ground_flht, …).
Querying¶
REST API¶
GET /api/scenarios/<scenario>/flooded-buildings
?threshold=<m> # min depth, default 0
&city=<city> # default rotterdam
&output=geojson|json # 'format' is reserved by DRF, so use 'output'output=geojson (default) returns a FeatureCollection (building footprint +
object_id, inun_depth, damage_total) ready for the map. output=json
returns a flat list with a count. Example:
curl 'http://localhost/api/scenarios/1_base_scenario/flooded-buildings?threshold=0.1'CLI¶
python manage.py query_flooded_buildings --scenario 6_IRP_100_years --threshold 0.1
python manage.py query_flooded_buildings --scenario 6_IRP_100_years --out /tmp/flooded.csvSPARQL endpoint¶
A read-only SPARQL endpoint is proxied at /sparql/ (routes only to
Oxigraph’s query endpoint — updates are not exposed). Example query:
PREFIX rht: <https://resilienthydrotwin.org/ontology#>
PREFIX inst: <https://resilienthydrotwin.org/ontology/instances#>
SELECT (COUNT(?b) AS ?flooded) WHERE {
?imp a rht:FloodImpact ;
rht:inScenario inst:scenario_rotterdam_1_base_scenario ;
rht:onBuilding ?b ;
rht:inundationDepth ?depth .
FILTER(?depth > 0.1)
}curl -s --data-urlencode 'query=...' http://localhost/sparql/PDOK/BAG enrichment (official building base)¶
The buildings above come from OpenStreetMap — that is what HydroMT-FIAT used
(setup_exposure_buildings: {asset_locations: "OSM", occupancy_type: "OSM"}), and
they carry only a FIAT-assigned object_id. To attach the official Dutch
registry (BAG, via PDOK) without re-running FIAT, enrich_with_bag spatially
joins each rht:Building to the BAG pand it best overlaps and adds:
| Term | Meaning |
|---|---|
rht:BagPand (geo:Feature) | An official BAG building (pand), with its own geo:asWKT footprint. |
rht:bagId | BAG identificatie (stable official id). |
rht:constructionYear | BAG bouwjaar. |
rht:status | BAG status (e.g. “Pand in gebruik”). |
rht:useFunction | BAG gebruiksdoel (e.g. “woonfunctie”), when present. |
rht:matchesPand (Building → BagPand) | The dominant (largest-overlap) match. |
rht:bagMatchOverlap | Fraction (0..1) of the OSM footprint covered by the pand. |
The FIAT flood results are untouched; rht:Building keeps its OSM footprint and
rht:BagPand is the official counterpart, linked by rht:matchesPand.
Getting the BAG file¶
Supply a local BAG pand vector file (GeoPackage/GeoJSON). Pull it from PDOK’s
BAG WFS with GDAL (the WFS driver pages for you); -spat … -spat_srs EPSG:4326
clips to a bbox:
ogr2ogr -f GPKG bag_panden.gpkg \
"WFS:https://service.pdok.nl/lv/bag/wfs/v2_0" bag:pand \
-spat 4.36 51.85 4.62 51.98 -spat_srs EPSG:4326 # Rotterdam bboxThe bag:pand layer carries identificatie, bouwjaar, status and
gebruiksdoel. Drop the file under the mounted raster root so the container sees it.
Running it¶
# Run AFTER build_knowledge_graph; loads additively (no CLEAR).
python manage.py enrich_with_bag --city rotterdam \
--bag /data/rasters/rotterdam/bag_panden.gpkg --loadOptions: --buildings <buildings.gpkg> (else OSM footprints are taken from a
scenario’s FIAT/spatial.gpkg), --min-overlap (default 0.1), the field-name
overrides --bag-id-field/--year-field/--status-field/--use-field, and
--out <file.nt>.
Run order:
enrich_with_bagloads additively. A laterbuild_knowledge_graph --replacedoesCLEAR ALLand wipes the BAG triples — re-runenrich_with_bagafterwards.
Querying with BAG¶
The flooded-buildings endpoint gains two params:
GET /api/scenarios/<scenario>/flooded-buildings
?bag=1 # add bag_id, construction_year, status, use_function
&geometry=osm|official # which footprint the GeoJSON returns (default osm)curl '.../flooded-buildings/?threshold=0.1&bag=1&geometry=official'A matched flooded building then looks like:
{object_id, inun_depth, damage_total, bag_id, construction_year, status, use_function}
with the official BAG polygon as its geometry.
Out of scope (for later): 3D-BAG building heights (
rht:buildingHeight, joined onbagId); and rebuilding FIAT directly on BAG footprints (which would make the flood depths themselves BAG-based).
Roads (flooded-road analysis)¶
The same pattern is applied to the road network — but unlike buildings (which
FIAT scored), there is no precomputed road flood product, so build_road_graph
creates it: it fetches OSM highway ways and samples the per-scenario flood
depth along each road.
| Term | Meaning |
|---|---|
rht:Road (geo:Feature) | An OSM road segment, with rht:osmId, rht:roadName, rht:roadClass, geo:asWKT (LineString). |
rht:RoadFloodImpact (sosa:Observation) | A road’s flood result in one scenario. |
rht:onRoad (RoadFloodImpact → Road) | The road an impact describes. |
rht:inScenario | The scenario (reused from buildings). |
rht:maxDepth, rht:meanDepth | Max / mean flood depth sampled along the road (m). |
How the depth is sampled. The native flood-depth COGs are ~660 Mpx — far too
big to stack — so for each scenario we read every timestep at a downsampled
overview, take the running per-cell maximum (an hmax surface), then sample that at
points densified every ~25 m along each road. A RoadFloodImpact is created only
where the road is wet; the raw maxDepth is stored so the flooded threshold stays a
query-time choice (same as buildings).
# Fetch OSM roads (Overpass, tiled) + sample flood depth, for one scenario:
python manage.py build_road_graph --city rotterdam --scenario 6_IRP_100_years \
--bbox "4.43,51.88,4.58,51.95" --load --replace-roadsOptions: --scenario (repeatable; default all), --bbox (WGS84; default = the depth
COG extent), --time-stride N (sample every Nth timestep — speed vs accuracy),
--sample-spacing (m), --downsample-width (px), --highways (OSM class filter),
--out. --replace-roads clears existing Road/RoadFloodImpact triples first.
Querying roads¶
GET /api/scenarios/<scenario>/flooded-roads?threshold=<m>&city=<city>&output=geojson|jsonReturns LineString GeoJSON (or a list) with osm_id, road_name, road_class,
max_depth, mean_depth. Example SPARQL — kilometres-equivalent count of flooded
roads above 0.3 m:
PREFIX rht: <https://resilienthydrotwin.org/ontology#>
PREFIX inst: <https://resilienthydrotwin.org/ontology/instances#>
SELECT (COUNT(?r) AS ?floodedRoads) WHERE {
?i a rht:RoadFloodImpact ;
rht:inScenario inst:scenario_rotterdam_6_IRP_100_years ;
rht:onRoad ?r ; rht:maxDepth ?d .
FILTER(?d > 0.3)
}In the Flood Impact Explorer, a “Flooded roads” toggle overlays the flooded road segments (amber→red by depth) on top of the flooded buildings; clicking a road shows its name, class and depths.
Note: roads need network access to OSM/Overpass at build time (it is tiled to stay under Overpass timeouts). Coverage and the OSM source can be swapped for the official PDOK NWB road network later.
Infrastructure¶
Service
graph(docker-compose.yml/.dev.yml): Oxigraph on port 7878, persisted to agraphdatavolume (dev:./data/oxigraph).api env:
SPARQL_QUERY_URL,SPARQL_UPDATE_URL,SPARQL_STORE_URL,ONTOLOGY_PATH(theontology/folder is mounted read-only at/ontology).nginx:
/sparql/→graph:7878/query(read-only).
Oxigraph was chosen because the flood query is an attribute join on
object_id, not a spatial operation. If true spatial SPARQL (GeoSPARQL functions likegeof:sfWithin) is needed later, switch thegraphservice to Apache Jena Fuseki with the GeoSPARQL assembler — the data model is unchanged.