Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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:

TermKindMeaning
rht:Buildingclass (geo:Feature)A building / exposure asset, keyed by the stable FIAT object_id.
rht:FloodImpactclass (sosa:Observation)One building’s flood result under one scenario.
rht:onBuildingFloodImpact → BuildingThe building an impact describes.
rht:inScenarioFloodImpact → RasterScenarioThe scenario the impact was computed under.
rht:hasFloodImpactBuilding → FloodImpactInverse of onBuilding.
rht:inundationDepthFloodImpact → xsd:floatWater depth at the building (m).
rht:damageTotal, rht:totalDamageFloodImpact → xsd:floatFIAT damage values.
rht:objectIdBuilding → xsd:integerStable FIAT id.
geo:asWKTBuilding → geo:wktLiteralFootprint 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.csv

Key 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.csv

SPARQL 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:

TermMeaning
rht:BagPand (geo:Feature)An official BAG building (pand), with its own geo:asWKT footprint.
rht:bagIdBAG identificatie (stable official id).
rht:constructionYearBAG bouwjaar.
rht:statusBAG status (e.g. “Pand in gebruik”).
rht:useFunctionBAG gebruiksdoel (e.g. “woonfunctie”), when present.
rht:matchesPand (Building → BagPand)The dominant (largest-overlap) match.
rht:bagMatchOverlapFraction (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 bbox

The 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 --load

Options: --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_bag loads additively. A later build_knowledge_graph --replace does CLEAR ALL and wipes the BAG triples — re-run enrich_with_bag afterwards.

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 on bagId); 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.

TermMeaning
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:inScenarioThe scenario (reused from buildings).
rht:maxDepth, rht:meanDepthMax / 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-roads

Options: --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|json

Returns 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

Oxigraph was chosen because the flood query is an attribute join on object_id, not a spatial operation. If true spatial SPARQL (GeoSPARQL functions like geof:sfWithin) is needed later, switch the graph service to Apache Jena Fuseki with the GeoSPARQL assembler — the data model is unchanged.