Seismic Hazard Mapping
Cross-reference earthquake hazard with building density over Italy to find where the most buildings sit in the highest-risk zones.
Open the live canvas: Seismic Hazard Building Risk
The problem
Italy sits at the collision of the African and Eurasian tectonic plates. Cities like L'Aquila and Amatrice have suffered devastating earthquakes in living memory. The goal: find where the highest concentration of buildings overlaps with the highest seismic hazard.
That requires joining two datasets that have nothing in common structurally — a raster and a vector — from two different organizations. H3 makes it trivial.
Data sources
- Seismic hazard — GEM Foundation OpenQuake Global Seismic Hazard Map (GSHM 2023). Each pixel is a Peak Ground Acceleration (PGA) value, in units of g, with a 475-year return period (10% chance of being exceeded in 50 years) on rock.
- Buildings — Overture Maps building footprints for Italy, as pre-partitioned GeoParquet files.
Canvas overview
The canvas is organized into five folders: one-time source data, the hazard pipeline, the buildings pipeline, the join, and the dashboard.
| Step | UDF | What it does |
|---|---|---|
| 0 | openquake_explorer | Downloads the OpenQuake GeoTIFF and saves it to S3 once |
| 1 | loading_seimic_data | Reads the TIF for a bounding box as a colored raster, for the map |
| 2 | hexagonified_seismic_hazard | Reads the raw PGA values and averages them per H3 cell |
| 3 | Overture_partitions | Shows Overture partition footprints across Italy |
| 3 | Overture_to_hex_single_file | Counts buildings and sums footprint area per H3 cell with DuckDB |
| 4 | join_over_buildings | Inner-joins both hex tables on H3 cell ID |
| 5 | risk_dashboard | Widget with threshold sliders, KPI tiles, map, charts and a table |
The canvas also has small map widgets (seismic_raster_map, seismic_hazard_hex_map, overture_partitions_map, buildings_hex_map) that visualize the output of each step.
1. Ingest the seismic GeoTIFF
The OpenQuake dataset is distributed as a ZIP containing a GeoTIFF. Rather than downloading it on every run, this UDF fetches it once and writes it to S3. Subsequent UDFs read from S3 directly.
View openquake_explorer code
@fused.udf
def udf(save_to_s3: bool = True):
import pandas as pd
import requests
import io
import zipfile
url = "https://cloud.openquake.org/public.php/dav/files/6SnFk2f92JEr76H"
@fused.cache # <-- fetch once, re-use on subsequent runs
def fetch_zip(url):
response = requests.get(url, timeout=60)
response.raise_for_status()
return response.content
content = fetch_zip(url)
zf = zipfile.ZipFile(io.BytesIO(content))
tif_name = "v2023_1_pga_475_rock_3min.tif"
s3_path = f"s3://fused-asset/demos/seismic_data/{tif_name}"
print(f"Extracting {tif_name} from ZIP...")
tif_bytes = zf.read(tif_name)
print(f"Extracted {len(tif_bytes):,} bytes")
if save_to_s3:
import s3fs
fs = s3fs.S3FileSystem()
with fs.open(s3_path, "wb") as f:
f.write(tif_bytes)
print(f"Saved to {s3_path}")
return pd.DataFrame([{
"filename": tif_name,
"size_bytes": len(tif_bytes),
"s3_path": s3_path,
"status": "saved" if save_to_s3 else "dry_run"
}])
@fused.cache means the network request only runs once. See efficient caching.
2. Load the seismic raster
Reads the GeoTIFF from S3 clipped to a bounding box. read_tiff_safe applies a colormap by default, so this UDF returns a colored image that's good for the raster map but is not PGA data. The next step reads the raw values instead.
View loading_seimic_data code
@fused.udf
def udf(
bounds: fused.types.Bounds = [6.6272, 36.6197, 18.4802, 47.0921],
chip_len: int = 512
):
path: str = 's3://fused-asset/demos/seismic_data/v2023_1_pga_475_rock_3min.tif'
common = fused.load('https://github.com/fusedio/udfs/tree/6750f1f/public/common/')
arr = common.read_tiff_safe(bounds, path, chip_len, max_pixel=10 ** 8)
print(arr)
return arr
3. Raster → H3 hexagons
hexagonified_seismic_hazard reads the raw PGA values (colormap=None), masks the nodata pixels, assigns each pixel to an H3 cell with arr_to_h3, and averages the pixels in each cell. A GeoTIFF becomes a flat table: one row per hex, one pga_g column.
Reading the S3 raster requires Fused credentials, so run this UDF on Fused (fused run without --engine local) or from the canvas.
@fused.udf
def udf(bounds: fused.types.Bounds=[6.6272, 36.6197, 18.4802, 47.0921], chip_len: int=512, h3_res: int=6):
"""Mean peak ground acceleration (PGA, in g; 10% chance of exceedance in 50 years = 475-yr return period)
per H3 cell, from the GEM global seismic hazard model v2023.1."""
import numpy as np
common = fused.load('https://github.com/fusedio/udfs/tree/6750f1f/public/common/')
path = 's3://fused-asset/demos/seismic_data/v2023_1_pga_475_rock_3min.tif'
# Read the raw PGA values. (loading_seimic_data returns a plasma-colored RGB image for the map,
# so aggregating its pixels would give a color value, not PGA.)
arr = np.asarray(common.read_tiff_safe(bounds, path, chip_len, max_pixel=10 ** 8, colormap=None), dtype=float)
if arr.ndim == 3:
arr = arr[0]
arr[~np.isfinite(arr) | (arr > 10)] = np.nan # nodata is float64 max
print(f"PGA raster {arr.shape}: min={np.nanmin(arr):.3f} max={np.nanmax(arr):.3f} g")
df = common.arr_to_h3(arr, bounds, res=h3_res)
df["pga_g"] = df["agg_data"].apply(lambda x: float(np.nanmean(x)) if np.isfinite(x).any() else np.nan)
df = df.dropna(subset=["pga_g"])[["hex", "pga_g"]]
# H3 as a hex string (e.g. 861e80007ffffff): readable in the table and what the maps/join expect
df["hex"] = df["hex"].map(lambda x: format(int(x), "x"))
print(df["pga_g"].describe())
return df
Output (15,815 hex cells for Italy at resolution 6):
| hex | pga_g |
|---|---|
| 863f244b7ffffff | 0.304 |
| 863f244c7ffffff | 0.322 |
| 863f2462fffffff | 0.203 |
| 863f26947ffffff | 0.280 |
| 861e85ce7ffffff | 0.198 |
Resolution 6 cells are ~36 km² — good for a country-scale overview. Resolution 7 (~5 km²) gives finer detail. See the resolution guide.
4. Explore Overture partitions
Overture building data is split into geographic partitions to keep file sizes manageable. Before reading buildings, you need to know which partition file covers your AOI. The interactive map below shows all partition boundaries across Italy — hover to see the file_id, then use it to build the path for the next step.
View overture_partitions_map widget JSON
{
"type": "fused-map",
"props": {
"basemap": "mapbox://styles/mapbox/dark-v11",
"centerLng": 12.5,
"centerLat": 42,
"zoom": 5,
"showControls": true,
"showScale": true,
"showBasemapSwitcher": true,
"showLegend": false,
"showLayerPanel": true,
"autoSend": false,
"autoSendDebounceMs": 600,
"layers": [
{
"id": "partitions-layer",
"name": "Overture Partitions",
"type": "deck-geojson",
"visible": true,
"sql": "SELECT file_id, ST_AsGeoJSON(geometry) as geometry FROM {{Overture_partitions}}",
"geometryColumn": "geometry",
"tooltip": [
"file_id"
],
"style": {
"opacity": 0.8,
"lineColor": [
250,
250,
250
],
"lineWidth": 1,
"fillColor": [
0,
0,
0,
0
]
}
}
]
}
}
5. Buildings → H3 with DuckDB
Each Overture partition contains tens of thousands of building polygons. This UDF reads one partition and uses DuckDB's spatial extensions to compute centroids, assign H3 cells, and aggregate total area and building count — all in a single SQL pass. The result has the same shape as the seismic layer from step 3: one row per H3 cell, ready to join.
@fused.udf
def udf(
path: str = "s3://fused-asset/overture/2026-03-18.0/theme=buildings/type=building/part=2/36.parquet",
res: int = 6
):
import geopandas as gpd
common = fused.load("https://github.com/fusedio/udfs/tree/4dde28e/public/common/")
con = common.duckdb_connect()
gdf = con.sql(f"""
SELECT
h3_latlng_to_cell(ST_Y(ST_Centroid(geometry::GEOMETRY)), ST_X(ST_Centroid(geometry::GEOMETRY)), {res}) AS hex,
sum(ST_Area_Spheroid(geometry::GEOMETRY)) as total_area_sqm,
count(1) as building_count
FROM read_parquet('{path}')
group by 1
order by 1
""").df()
return gdf.reset_index(drop=True)
