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.

Montandon Use Case 1: Population and Infrastructure Risk Exposure

How many people and which assets are exposed to a given hazard, before any event, as a baseline for preparedness and planning.

Overview

This notebook fetches data from several sources to calculate the baseline risk exposure of Lebanon’s population to various hazards. It uses OCHA’s Common Operational Datasets for administrative boundaries, INFORM subnational risk scores for hazard exposure, and WorldPop population estimates.

Code and prose have been hidden by default to focus on the map and stat outputs, but you can click on the toggles titled “Source” and “Notebook Cell” to see the underlying logic and data processing steps.

Notebook Cell

Methodology and Data Sources

Hazard Types

  1. Riverine Flood

  2. Tropical Cyclone

  3. Earthquake

  4. Wildfire

Data Sources

  1. INFORM Subnational Risk Index — hazard scores per municipality

  2. Population data — WorldPop constrained population raster

  3. Administrative boundaries — OCHA COD-AB admin3 (municipality) polygons

Data Prep

Notebook Cell

Boundaries

Let’s fetch data for Lebanon, since it has precise, up-to-date INFORM subnational risk scores (see below).

OCHA’s Common Operational Datasets (COD-AB) are published as cloud-native GeoParquet by the Humanitarian Data Exchange on source.coop/hdx/cod-ab, one file per country/admin level. Admin 3 (adm3, ~1,600 municipalities) lines up with the municipality granularity used by the INFORM risk index below.

The catalog is organized as a STAC tree (per the Portolan SDI spec this dataset is built with): root catalog → country → version → admin-level collection → asset. Each collection resolves to a predictable path, so we build the file’s URL directly from a base URL and a country/version/admin-level suffix, fetch it, and keep only the columns we need.

Inform Subnational Risk Index

INFORM Subnational Risk is published for roughly 30 countries at various spatial resolution (admin 1 - 3), potentially ad-hoc.

For example, the most recent data for Lebanon appears to be from 2024.

For the purposes of this notebook, the key variables provided by INFORM appear to be:

  1. NATURAL

    1. Flood (riverine flood)

    2. Storm (tropical cyclone)

    3. Earthquake & Tsunami

    4. Forest Fire (wildfire)

  2. VULNERABILITY

  3. INFRASTRUCTURE

  4. LACK OF COPING CAPACITY

  5. RISK

Matching boundaries to the risk index

INFORM’s admin3 codes are Lebanon’s national cadastre codes (per the workbook’s own Table of Contents), not OCHA P-codes, so the two datasets aren’t interoperable via their code columns. We join on normalized district + municipality names instead. Both sides also carry placeholder rows for disputed/unclaimed cadastral zones ("Litige" in INFORM, "Conflict" in OCHA) that share a generic label rather than a real name, so those are excluded from the match rather than joined blindly.

Name-matching is not ideal, but works well here because OCHA’s own admin3 level for Lebanon is the cadastre too, not a coincidence: its STAC collection metadata labels admin_3_name as "Cadaster (Manatek Ikaria)", and its caveats field documents that Lebanon’s admin3 P-codes are purely numeric and follow a different sequence than admin1/2 — an OCHA-acknowledged P-code nesting conflict from the post-2014 governorate restructuring, independent of the INFORM/OCHA mismatch above.

Hazard Types and Risk Threshold

Population Exposure

WorldPop publishes constrained population estimates as ~100m GeoTIFFs, projected for future years including 2026. The file isn’t cloud optimized, but is small enough (~3.7MB) to fetch it fully.

We sum population per pixel within each admin3 polygon (zonal statistics) to get total population per municipality.

Population at Risk by Hazard

INFORM classifies its 0-10 risk scores into five bands using hierarchical cluster analysis: Very Low (0.0–2.1), Low (2.2–3.1), Medium (3.2–4.8), High (4.9–6.7), Very High (6.8–10.0) — see the INFORM concept & methodology report. Each hazard sub-indicator (Flood, Storm, Earthquake & Tsunami, Forest Fire) uses the same 0-10 scale as the overall RISK score.

These bands are published for the global country-level index; INFORM’s subnational methodology doesn’t spell out per-indicator thresholds explicitly, so treating the “High” cutoff (> 4.9) as the “at risk” threshold is a reasonable but non-official choice, applied uniformly across all five hazards. The map and stats below both use this same fixed threshold, so both work identically with or without a running kernel — toggling a layer or reading a stat never needs to recompute anything live.

Notebook Cell
import io

import geopandas as gpd
import pandas as pd
import requests

boundaries_url = "https://data.source.coop/hdx/cod-ab"
adm3_path = "lbn/latest/adm3/original.parquet"

response = requests.get(f"{boundaries_url}/{adm3_path}")
lebanon_admin3_gdf = gpd.read_parquet(io.BytesIO(response.content))
# Keep only selected columns
lebanon_admin3_gdf = lebanon_admin3_gdf[["adm1_name", "adm2_name", "adm3_name", "adm3_pcode", "geometry"]]

inform_lebanon_url = "https://drmkc.jrc.ec.europa.eu/inform-index/Portals/0/InfoRM/2024/Subnational/Lebanon/20241010%20-%20INFORM_LEBANON_2024_v1.xlsx"

inform_lebanon_df = pd.read_excel(
    inform_lebanon_url, sheet_name="INFORM Lebanon 2024", header=1
).dropna(subset=["MUNICIPALITY"])

inform = inform_lebanon_df[inform_lebanon_df["MUNICIPALITY"] != "Litige"].copy()
inform["key"] = (inform["DISTRICT"] + inform["MUNICIPALITY"]).str.lower().str.replace(r"[^a-z0-9]", "", regex=True)

ocha = lebanon_admin3_gdf[lebanon_admin3_gdf["adm3_name"] != "Litige"].copy()
ocha["key"] = (ocha["adm2_name"] + ocha["adm3_name"]).str.lower().str.replace(r"[^a-z0-9]", "", regex=True)

hazard_cols = ["Flood", "Storm", "Earthquake & Tsunami", "Forest Fire", "RISK"]

risk_boundaries_gdf = ocha[["key", "geometry"]].merge(
    inform[["key", "GOVERNORATE", "DISTRICT", "MUNICIPALITY"] + hazard_cols], on="key"
)
print(f"Matched {len(risk_boundaries_gdf)} of {len(inform)} municipalities ({len(risk_boundaries_gdf) / len(inform):.1%})")

hazard_options = {
    "Overall Risk": "RISK",
    "Riverine Flood": "Flood",
    "Tropical Cyclone": "Storm",
    "Earthquake": "Earthquake & Tsunami",
    "Wildfire": "Forest Fire",
}

# Focusing on risk scores greater than or equal to 4.9, which is the threshold for "high" risk in INFORM's scoring system.
RISK_THRESHOLD = 4.9

from rasterstats import zonal_stats
import rasterio

pop_url = "https://data.worldpop.org/GIS/Population/Global_2015_2030/R2025A/2026/LBN/v1/100m/constrained/lbn_pop_2026_CN_100m_R2025A_v1.tif"
response = requests.get(pop_url)

with rasterio.MemoryFile(response.content) as memfile:
    with memfile.open() as src:
        stats = zonal_stats(
            risk_boundaries_gdf,
            src.read(1),
            affine=src.transform,
            stats="sum",
            nodata=src.nodata,
        )

risk_population_gdf = risk_boundaries_gdf.copy()
risk_population_gdf["population"] = [round(s["sum"] or 0) for s in stats]
risk_population_gdf[["MUNICIPALITY", "population"]].sort_values("population", ascending=False).head()
Matched 1545 of 1545 municipalities (100.0%)
Loading...

Population at Risk by Hazard

Source
import matplotlib
from lonboard import Map, PolygonLayer
from lonboard.colormap import apply_continuous_cmap
from manywidgets import Column, Row
from manywidgets.lonboard import LayerToggle

pop_min = risk_population_gdf["population"].min()
pop_max = risk_population_gdf["population"].max()

risk_layers = {}
for label, col in hazard_options.items():
    at_risk_gdf = risk_population_gdf[risk_population_gdf[col] > RISK_THRESHOLD]
    normalized = (at_risk_gdf["population"] - pop_min) / (pop_max - pop_min)
    colors = apply_continuous_cmap(normalized.to_numpy(), matplotlib.colormaps["YlOrRd"])
    risk_layers[label] = PolygonLayer.from_geopandas(
        at_risk_gdf,
        get_fill_color=colors,
        get_line_color=[80, 80, 80],
        line_width_min_pixels=0.5,
        opacity=0.8,
        visible=(label == "Overall Risk"),
    )

hazard_toggles = [
    LayerToggle(layer=risk_layers[label], value=(label == "Overall Risk"), label=label)
    for label in hazard_options
]

risk_map = Map(
    list(risk_layers.values()),
    view_state={"longitude": 35.86, "latitude": 33.95, "zoom": 7.5},
    height=500,
)

Column(Row(*hazard_toggles, gap="8px"), risk_map, gap="8px")
Source
from manywidgets import Column, Row, Stat

def stat_for(label, col):
    at_risk_gdf = risk_population_gdf[risk_population_gdf[col] > RISK_THRESHOLD]
    return Stat(
        label=label,
        value=f"{round(at_risk_gdf['population'].sum()):,.0f}",
        unit=f"across {len(at_risk_gdf)} municipalities",
    )

hazard_stats = [stat_for(label, col) for label, col in hazard_options.items()]

Column(
    Row(*hazard_stats[:3], gap="8px"),
    Row(*hazard_stats[3:], gap="8px"),
    gap="8px",
)