Recipes¶
Keep a national copy fresh, every night¶
from ispra_idrogeo import LANDSLIDE_LAYERS, IdroGEO, mosaic, opendata
async with IdroGEO() as idrogeo:
for layer in LANDSLIDE_LAYERS:
got = await idrogeo.download(opendata(layer))
print(layer, "updated" if got.changed else "unchanged")
for name in ("pai_frane", "idraulica"):
await idrogeo.fetch(mosaic(name))
The first run downloads about 3 GB. Put it in a cron job: the following runs cost a few kilobytes, plus whatever ISPRA really changed.
Only what changed since last time¶
The nightly download above re-reads a whole file when anything in it changed. To keep your own copy (a database, a GeoParquet file) up to date, ask the API for the records changed since your last run instead:
from datetime import UTC, datetime
from ispra_idrogeo import IdroGEO
since = datetime(2026, 11, 1, tzinfo=UTC) # saved from your last run
async with IdroGEO() as idrogeo:
changed = [f async for f in idrogeo.iter_landslides(modified_since=since, select=["*"])]
print(len(changed)) # 20 landslides changed in two days, with their geometry
next_since = max(datetime.fromisoformat(f["modified"]) for f in changed)
- Save the largest
modifiedyou received and pass it next time. The filter is "at or after", so the last record comes back once more: write byid_frana(upsert) and it does no harm. select=["*"]brings every field and the geometry:geomin EPSG:32632,geom_polygon/geom_linestringwhen there is one,pointin lon/lat.iter_eventsandsitestakemodified_sincetoo.- A removed landslide does not show up as changed: it just stops being listed. Now and then, compare your ids with the full list, one call of about 15 seconds for 721,377 ids:
ids = {f["id_frana"] async for f in idrogeo.iter_landslides(select=["id_frana"], page_size=100_000)}
Watch for changes¶
idrogeo watch --once >> changes.jsonl # one round, e.g. every hour from cron
idrogeo watch --kind events --interval 900 # or keep running, every 15 minutes
Each line is a landslide, an event or a monitoring site that changed since
the previous round, with "kind" saying which. The first run starts from
now (or from --since); after that <dir>/watch.json remembers where each
kind stands, so a stopped watch resumes without missing or repeating a
change. In Python, ispra_idrogeo.watch.changes(idrogeo, kind, mark) does
one round.
Landslides per region (the map above)¶
from ispra_idrogeo import IdroGEO
async with IdroGEO() as idrogeo:
for region in await idrogeo.summary("iffi", "regioni"):
counts = await idrogeo.summary("iffi", "regioni", region["cod_reg"])
hazard = await idrogeo.summary("pir", "regioni", region["cod_reg"])
density = counts["frane_totali"] / hazard["ar_kmq"] * 100
print(f"{region['nome']:<22} {counts['frane_totali']:>8,} {density:5.0f} per 100 km²")
# Piemonte 37,786 149 per 100 km²
# Valle d'Aosta 5,812 178 per 100 km²
# Lombardia 141,970 595 per 100 km²
# …
summary("iffi", …) gives landslide counts (also by type, in
frane_stats); summary("pir", …) gives hazard and risk indicators: areas,
residents, families, buildings, businesses and cultural heritage in each
landslide (*_fr_*) and flood (*_idr_*) hazard class. Levels are
"italia", "macroregioni", "regioni", "province" and "comuni", by
ISTAT code.
Every municipality, in one table¶
from datetime import timedelta
from ispra_idrogeo import IdroGEO
async with IdroGEO() as idrogeo:
rows = await idrogeo.municipalities("pir", max_age=timedelta(days=30))
print(len(rows)) # 7899
worst = sorted(rows, key=lambda r: r["pop_fr_p3"] + r["pop_fr_p4"], reverse=True)[:3]
for r in worst:
print(r["nome"], r["pop_fr_p3"] + r["pop_fr_p4"], f"{r['popfrp3p4p']:.1f} %")
# Napoli 42390 4.6 % ← residents in landslide hazard P3 + P4, and their share
# Genova 30148 5.4 %
# Sanremo 14607 27.6 %
One call lists the municipalities, then one call each asks for its
indicators, 8 at a time: about a minute and 17 MB for all of Italy. Each
answer is saved as <directory>/pir_comuni/<ISTAT code>.json, so the next
run reads it back from disk (under two seconds), an interrupted run resumes
where it stopped, and max_age decides when an answer is old enough to ask
again. The indicators come from the 2020-2021 hazard mosaics and the 2021
census, so a monthly refresh is plenty.
municipalities("iffi") does the same with landslide counts, and
summaries(summary, "regioni" | "province" | "comuni") with any level.
Translate the codes¶
The data stores codes (tipo_movimento = 4); the API has 73 code lists that
say what they mean, in Italian, English, French and German. decode adds a
<field>_label next to every coded field it knows:
from ispra_idrogeo import IdroGEO, decode
async with IdroGEO() as idrogeo:
labels = await idrogeo.code_labels(lang="en") # one call per code list
record = decode(await idrogeo.landslide("0017680500"), labels)
print(record["tipo_movimento_label"], "·", record["stato_attivita_1liv_label"])
# rapid debris flow · active/reactivated/suspended
It works on monitoring sites too (strumentazione = [15, 22, 25] becomes
['inclinometer', 'topographic monitoring', 'Satellite SAR interferometry'])
and on the rows of a shapefile, where the field is called tipo_movim.
CODE_DICTIONARIES lists which code list decodes which field, and
labels("cause", "it") gives one code list as {code: label}. A code that
is not in its list becomes None rather than a guess.
Walk through every landslide or event¶
from datetime import date
from ispra_idrogeo import IdroGEO, event_filter, landslide_filter
async with IdroGEO() as idrogeo:
active_sinkholes = landslide_filter(movement=6, activity=100)
print(len([f async for f in idrogeo.iter_landslides(active_sinkholes)])) # 217
tuscany_2023 = event_filter(region=9, start=date(2023, 1, 1), end=date(2024, 1, 1))
async for event in idrogeo.iter_events(tuscany_2023): # 8 events
print(event["uid"], event["computed_date"], event["comune_nome"])
iter_landslides and iter_events ask for one page after another
(page_size, 1,000 by default) and stop at the last one. They sort by a
unique key (id_frana, uid) so pages do not overlap. An empty filter
matches everything; search_landslides and search_events return a single
page, if you prefer to page yourself.
For the landslides of an area, iter_landslides_in checks each record's
own region, province or municipality, because the search has no reliable
area filter (see the traps). It reads the whole inventory with a few fields,
about 20 seconds for Italy, and gives the exact counts (Campania 55,254,
Sarno 53):
async with IdroGEO() as idrogeo:
sarno = [f async for f in idrogeo.iter_landslides_in(municipality=65135)]
debris_flows = idrogeo.iter_landslides_in(region=15, filters=landslide_filter(movement=5))
landslide_filter, event_filter and site_filter build the filters, because each
search reads them its own way: landslides want {"tipo_movimento": 6} but
{"stato_attivita_1liv": "eq.100"}, events and sites want operators
everywhere, and a range of dates is an "and" of two conditions. Codes are
ISTAT codes for areas and the code lists' codes for the rest; a list of
codes means "any of these". You can still pass a plain dict if you know
the syntax.
One landslide, in full¶
async with IdroGEO() as idrogeo:
record = await idrogeo.landslide("0017680500")
print(len(record), record["stato"]) # 142 validato_ispra
# activations, causes, state and velocity, damage, interventions, precursors…
The full record of a validated landslide. Many fields are often empty, so
check before you rely on them. An unknown id raises IdrogeoError (IdroGEO
answers 401 to it, not 404).
API rows on a map¶
frame turns API rows into a GeoDataFrame in the right coordinate system:
from ispra_idrogeo.geo import frame
async with IdroGEO() as idrogeo:
sites = frame(await idrogeo.sites()) # "point": lon/lat, EPSG:4326
cams = frame(await idrogeo.webcams(), geometry="geom") # EPSG:3857, as IdroGEO sends it
changed = frame(
[f async for f in idrogeo.iter_landslides(modified_since=since, select=["*"])],
geometry="geom_polygon", # or "geom" (point, EPSG:32632), "geom_linestring"
)
Who is watching a landslide: monitoring sites and webcams¶
async with IdroGEO() as idrogeo:
active = await idrogeo.sites({"stato_monitoraggio": "eq.1"})
print(len(active)) # 558 of 1,063 sites are active
site = active[0]
print(site["uid"], site["point"], site["id_frane_correlati"])
# SIABR001 [14.03, 42.24] ['0680013800'] ← lon/lat, and its IFFI landslides
for station in await idrogeo.stations(site["uid"]):
print(station["uid"], station["strumentazione"])
cams = await idrogeo.webcams() # 14, all in Lombardy
A site is a place where an agency (often an ARPA) monitors a landslide.
It has stations, and each station has instruments (inclinometers,
piezometers, rain gauges…, decoded by the strumento_monitoraggio
dictionary). id_frane_correlati links 880 sites to their IFFI landslides,
so you can tell which landslides someone is already watching. Look a site up
by its uid ("SIPIE001"), not by its id.
Is this place at risk? Hazard at a point¶
async with IdroGEO() as idrogeo:
here = await idrogeo.hazard_at(40.829, 14.617) # the hills above Sarno
print(here.landslide, here.flood, here.municipality_name, here.slope_deg)
# P4 None Sarno 12.3
async with IdroGEO() as idrogeo:
venice = await idrogeo.hazard_at(45.438, 12.330)
print(venice.flood, venice.flood_scenarios) # P3 ('P3', 'P2', 'P1')
One request, about a second, to IdroGEO's public map service: no 1.2 GB
mosaic to download. You get the PAI landslide class (P4 very high,
P3 high, P2 medium, P1 moderate, AA attention area), the flood
scenarios that reach the point (P3 frequent floods, P2 every 100–200
years, P1 rare and extreme), the municipality (ISTAT code, ready for
summary("pir", "comuni", code)) and the slope in degrees.
lithology=True adds the rock type, but takes about 16 seconds more.
Landslides near a point¶
async with IdroGEO() as idrogeo:
near = await idrogeo.landslides_near(40.838, 14.630, radius_m=500)
for f in near[:3]:
print(f["id_frana"], f["nome_tipo"], f["nome_com"], f["distance_m"], f["shapes"])
# 0640689600 Colamento rapido Quindici 397.7 ['polygon', 'point'] ← a rapid debris flow
Every IFFI landslide within radius_m (up to 5 km), nearest first, with its
type and area, distance_m (0 when the point is on it) and the shapes it is
mapped as. The distance is computed from the real outline, not from a
centre point. Also: idrogeo hazard 40.829 14.617 and idrogeo near 40.838 14.630.
Offline, many places at once, your own SQL: a local warehouse¶
hazard_at asks the map service one point at a time. For thousands of
points, for work offline, or for questions of your own,
pip install "ispra-idrogeo[warehouse]" keeps the layers in one DuckDB file:
idrogeo sync frane_piff pai_frane idraulica # once; then only what changed
idrogeo sql "SELECT nome_reg, count(*) AS n FROM frane_piff GROUP BY 1 ORDER BY n DESC"
idrogeo sql # the tables and their columns
from ispra_idrogeo.warehouse import Warehouse
async with IdroGEO() as idrogeo:
with Warehouse() as warehouse: # ~/.cache/ispra-idrogeo/idrogeo.duckdb
await warehouse.sync(idrogeo, ["frane_piff", "pai_frane", "idraulica"])
warehouse.hazard_at(40.8205, 14.6297) # Sarno: {'landslide': 'P4', 'flood': None, …}
warehouse.landslides_in(municipality=65135) # Sarno: 53 landslides, by type
warehouse.query(
"SELECT p.class, count(*) AS landslides FROM frane_piff f "
"JOIN pai_frane p ON ST_Intersects(p.geom, f.geom) "
"WHERE f.regione = ? GROUP BY 1 ORDER BY 1",
[14],
) # Molise, in 9 s: P4 2,904 · P3 8,242 · P2 569 · P1 2,377 · AA 659
# (IFFI points by the class of the PAI polygons they fall in; a point in
# two overlapping polygons counts twice)
sync downloads each layer only if it changed (as fetch does) and loads it
with an R-tree index, in one transaction: a failed load leaves the old table
in place. Every table is in EPSG:32632 metres, the flood mosaic reprojected
from EPSG:3035; pai_frane and idraulica carry a class column (P4 …
P1, AA; P3 … P1). Points go in as latitude and longitude, as in
hazard_at; geometries come out of query as WKT.
With the three national layers the file weighs 2.6 GB. Loading took 9 s for
the 721,377 landslide points, 55 s for the 928,542 landslide hazard polygons
and 9 minutes for the 85,460 flood polygons, whose reprojection is the slow
part; a point query then takes 10–40 ms. Checked on 4 November 2026: the
landslides counted per municipality match the API (Sarno 53, Casamicciola
Terme 213, Trivento 921), the points agree with hazard_at (Sarno P4, Venice
flood P3), and the flood areas add up to 16,283, 30,271 and 42,460 km² for P3,
P2 and P1, against the API's 16,224, 30,196 and 42,376 km².
Warehouse(read_only=True) opens the file for readers (the MCP server, a
notebook, idrogeo sql) and switches off DuckDB's access to files, URLs and
other databases, so SQL written by someone else, or by an agent, reads the
tables and nothing more. DuckDB downloads its spatial extension the first
time.
Geology, aerial photos, bibliography¶
async with IdroGEO() as idrogeo:
units = await idrogeo.lookup("formazioni", "argille", limit=2)
# [{'sigla': 'AAC', 'nome_unita': 'ARGILLE E ARENARIE GLAUCONITICHE DI CATENANUOVA', …}, …]
flights = await idrogeo.lookup("voli", "1954") # [{'id': '0001', 'nome': 'Volo GAI', …}]
The reference lists behind the landslide records, searched by text:
geological units (formazioni), aerial survey flights (voli),
bibliography (bibliografie), institutions (istituzioni), professional
bodies (ordini) and funding sources (finanziamenti). Ten answers by
default; limit asks for more. Also idrogeo lookup formazioni argille.
For AI agents: the MCP server¶
idrogeo-mcp is an MCP server, so Claude
Desktop, an IDE or your own agent can answer questions like "how many people
live in high landslide hazard areas in Sarno, and is anyone monitoring the
landslides there?" or "is this address in a flood zone?". For Claude Desktop, add it to
claude_desktop_config.json:
As an always-on service, for an agent that talks HTTP (for example
mcp-geo-server's
chat and map), serve it over streamable HTTP at /mcp:
Every option also reads an environment variable, so a compose file needs no
command line, and GET /health answers {"status": "ok"}:
services:
idrogeo-mcp:
image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
command: uvx --from "ispra-idrogeo[mcp]>=0.6" idrogeo-mcp
environment:
IDROGEO_MCP_TRANSPORT: streamable-http
IDROGEO_MCP_HOST: 0.0.0.0
IDROGEO_MCP_PORT: "8767"
IDROGEO_DATA_DIR: /data/idrogeo # for download_dataset
volumes:
- idrogeo-data:/data/idrogeo
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8767/health')"]
On 127.0.0.1 the SDK refuses requests with a foreign Host header (DNS
rebinding protection); on 0.0.0.0 it does not, unless you list the names
clients use: IDROGEO_MCP_ALLOWED_HOSTS=idrogeo-mcp:*,localhost:*.
| Tool | What the agent gets |
|---|---|
hazard_at |
Landslide and flood hazard, municipality and slope of a point |
landslides_near |
The landslides around a point, nearest first |
find_area |
The ISTAT code of a region, province or municipality from its name |
area_risk |
Hazard and exposure: residents, buildings, heritage per hazard class |
area_landslides |
IFFI counts by type of movement, events, monitoring |
landslide |
One landslide in full, codes decoded |
search_landslides / search_events |
Landslides by type and activity; events by area and date |
monitoring_sites / webcams |
Who is watching, with which instruments |
rank_areas |
The top (or bottom) regions, provinces or municipalities by any indicator, optionally inside a region or province |
compare_areas |
Up to 20 areas side by side, so the agent does not do arithmetic in its head |
code_list |
Any code list, in four languages |
download_dataset |
A layer downloaded (only if changed) to the local folder |
warehouse_schema / warehouse_sql |
The tables of the local warehouse, and one read-only SQL query on them |
Names are matched without accents or apostrophes (forli finds Forlì),
and a name that matches nothing comes back with "did you mean" suggestions.
| Resource | What it holds |
|---|---|
idrogeo://indicators |
What popfrp3p4p, pop_idr_p2 and the other indicator keys mean |
idrogeo://datasets |
The layers, mosaics and area names download_dataset accepts |
idrogeo://codes/{name} |
One code list, in English |
Two prompts, area_profile and recent_events, give a client ready-made
questions ("a risk profile of Sarno", "landslide events in Liguria since
October") that use the tools the right way and credit the source.
Every tool but download_dataset only reads. Searches stop at limit rows
(100 by default, 1,000 at most), so an agent never receives 700,000
landslides at once, and IdroGEO's refusals come back as readable errors.
rank_areas keeps the summaries it reads on disk for 30 days: the first
national ranking of municipalities asks IdroGEO 7,899 times (a few minutes),
a region's ranking a few hundred.
Bring your own HTTP client¶
import httpx
from ispra_idrogeo import IdroGEO
http = httpx.AsyncClient(timeout=httpx.Timeout(30, read=600), proxy="http://proxy:3128")
async with IdroGEO(http, retries=0) as idrogeo: # your client, your retry policy
...
await http.aclose() # the client is yours: IdroGEO does not close it
Handle errors¶
import httpx
from ispra_idrogeo import IdrogeoError
try:
folder = await idrogeo.fetch(opendata("frane_piff", "puglia"))
except IdrogeoError as exc:
# IdroGEO answered, but not with what was asked: 404, API refusal, not JSON.
print("IdroGEO said no:", exc)
except httpx.HTTPError as exc:
# Network problems still failing after the retries.
print("network:", exc)
An unknown layer, area or mosaic name raises ValueError before any request.
Example notebooks¶
examples/ has three notebooks run on the live service, with
their outputs: a town and its landslides (Sarno, from exposure figures
to a map of its debris flows), Italy in numbers (who lives in hazard
areas, municipality by municipality) and what changed (following the
inventory day by day).