
Goal: go from an HTTP request to a GeoDataFrame you can look at, filter, and map.
This tutorial uses a live, already-running instance of the Aging Dams Risk API:
BASE_URL = "http://149.165.154.170:30080"
You do not need to install or run anything server-side — every notebook in this
tutorial just talks to this API over plain HTTP requests.
Note: this is an internal address. If a request times out, you're probably on a network that can't reach it (ask your instructor about VPN/network access).
Throughout this tutorial we'll keep coming back to one dam as our running example:
Mountain Dell (UT00221) — a high-hazard dam with a rich mix of impacted
infrastructure (hospitals, railroads, highways, protected land, population), which
makes it a good one to look at in detail.
import json
import requests
import pandas as pd
import geopandas as gpd
BASE_URL = "http://149.165.154.170:30080"
DAM = "UT00221" # Mountain Dell
# Every endpoint in this API is a plain GET (or POST) — same pattern every time:
# 1. build the URL 2. requests.get/post 3. .raise_for_status() 4. .json()
health = requests.get(f"{BASE_URL}/healthz", timeout=10)
health.raise_for_status()
print("API status:", health.json())
Everything in this tutorial is just HTTP requests to a web address — no different in principle from typing a URL into a browser. Every such API documents itself as an interactive webpage using a standard called OpenAPI/Swagger: a live page listing every endpoint, what parameters it takes, and a button to try it right there in your browser before writing a single line of Python.
This API's docs page is at http://149.165.154.170:30080/docs
— open that in a new tab now. Endpoints are grouped (Risk, Features, ...);
click a group to expand it, click an endpoint to see its parameters, then click
"Try it out" to fill in real values and execute the request — no code
required:

Everything we do below with requests.get(...) is exactly what that "Try it out"
button does under the hood — we're just doing it in Python instead of a browser,
so we can automate it and work with the results.
GET /risk/targets lists every variable ("target") the API can report on — population,
hospitals, protected land, pipelines, and so on. Anything you see in this list can be
plugged into targets= on the endpoints below.
.json()¶Every call so far has ended with .json(). response.json() takes the raw response
body — a JSON-formatted string — and parses it directly into native Python objects,
so you never have to do json.loads(response.text) by hand. The mapping is
mechanical:
| JSON | Python |
|---|---|
object {...} |
dict |
array [...] |
list |
| string | str |
| number | int / float |
true / false |
True / False |
null |
None |
So in the cell below, targets_response comes back as a Python dict (the JSON
body is a {...} object with a "targets" key), and targets_response["targets"]
is a list of strings (the JSON array behind that key) — no manual parsing step in
between. If the response body isn't valid JSON (a timeout page, an HTML error from
a proxy, ...), .json() raises requests.exceptions.JSONDecodeError instead of
silently handing you garbage — exactly the kind of failure you want to be loud.
targets_response = requests.get(f"{BASE_URL}/risk/targets", timeout=10).json()
# json.dumps() does the opposite of what .json() just did: it turns a Python object
# back into JSON text, so we can see exactly what came over the wire before Python
# ever touched it.
print(json.dumps(targets_response, indent=2))
That's the same shape as the response body you'd see in the Swagger UI's "Try it
out" panel — a JSON object with two keys, one of them a JSON array. By the time it
reached us, .json() had already converted that into native Python:
print(type(targets_response), type(targets_response["targets"])) # dict, list
targets = targets_response["targets"]
print(f"{len(targets)} available targets:")
targets
That flat list doesn't tell you what kind of value you'll actually get back for
each target — and that matters a lot once you start calling /risk/summary or
/risk/metrics. The same response also includes by_geometry_type, which groups
every target into one of four buckets:
targets_response["by_geometry_type"]
Here's how to read those four buckets:
point (aviation, hazardous_waste, hospitals, power_plants, wwtp) —
each is a single point feature (a hospital building, a power plant, ...). The value
you get back is a count: how many of those points intersect the dam's zone.line (ng_pipelines, railroads, transportation) — each is a line feature
(a pipeline segment, a railroad segment, a road segment). The value is still a
count of intersecting line rows — not a length in miles. transportation
in particular counts every road class combined (interstate + US highway + state
route + local), not just one type.polygon (gap_status, svi_tracts) — each is a polygon feature (a protected
area parcel, a census tract). The value is a count of intersecting polygon rows
— not their combined area. gap_status here counts any GAP status level (1–4),
which is broader than /risk/features/multi.geojson's GAP Status 1–2 filter (the
discrepancy Notebook 4 digs into).derived (population, svi_score, total_interstate_impact_mile,
transmission_max_voltage, ...) — these are not counts at all. Each is its own
precomputed metric — people, a 0–1 score, miles, kilovolts, megawatts, acres,
percent, beds — depending on the target. /risk/metrics's response includes a
units block that tells you exactly which unit applies to each one.So: point/line/polygon → "how many," derived → "how much, in its own unit."
Knowing which bucket a target falls into before you call /risk/summary tells you
how to read the number you get back.
/risk/summary¶Given a dam and a list of targets, /risk/summary returns fast, precomputed counts —
"how many hospitals/railroads/etc. intersect this dam's inundation zone." This is the
cheapest way to get a first read on a dam's risk profile.
summary = requests.get(
f"{BASE_URL}/risk/summary",
params={
"damnumber": DAM,
"targets": ["population", "hospitals", "railroads", "svi_tracts", "gap_status", "transportation"],
},
timeout=10,
).json()
summary
/risk/metrics¶/risk/summary is quick counts for one dam. /risk/metrics is the fuller,
canonical table — and it accepts damnumber=all to return every dam at once, which
is exactly what you want when you need to rank or sort dams (we'll use this a lot in
Notebook 2). The response is {"items": [...], "units": {...}} — items converts
straight into a pandas.DataFrame.
This is where the point/line/polygon buckets from before mean something
different again. /risk/summary treats all of them the same way — a plain count.
/risk/metrics does not: its own units block spells out points → count,
lines → length (miles), polygons → area (mi²). So for a line target like
railroads, the two endpoints aren't just formatted differently — they're
answering different questions. Let's see it side by side instead of getting
surprised by it later.
railroads_metrics = requests.get(
f"{BASE_URL}/risk/metrics", params={"damnumber": DAM, "targets": "railroads"}, timeout=10
).json()
railroads_summary = requests.get(
f"{BASE_URL}/risk/summary", params={"damnumber": DAM, "targets": "railroads"}, timeout=10
).json()
print("/risk/metrics ->", railroads_metrics["items"][0]["railroads"], railroads_metrics["units"]["length"], "of track")
print("/risk/summary ->", railroads_summary["counts"]["railroads"], "intersecting railroad segments")
Same dam, same target name, two genuinely different numbers — miles of track vs. a count of segment rows. Neither is wrong; they're just not the same measurement. Before trusting a number from either endpoint, check which one you actually called, not just which target name you passed.
metrics = requests.get(
f"{BASE_URL}/risk/metrics",
params={"damnumber": DAM, "targets": "svi_score"},
timeout=10,
).json()
df = pd.DataFrame(metrics["items"])
df
Everything so far has been tabular. Now let's pull actual geometry:
/risk/zone.geojson returns the dam's inundation zone as a GeoJSON FeatureCollection.
geopandas.GeoDataFrame.from_features turns that directly into a GeoDataFrame —
notice it displays like a normal spreadsheet/DataFrame in Jupyter, just with an extra
geometry column (JupyterLab even renders a little thumbnail of the shape).
zone_json = requests.get(
f"{BASE_URL}/risk/zone.geojson", params={"damnumber": DAM}, timeout=10
).json()
zone_gdf = gpd.GeoDataFrame.from_features(zone_json["features"], crs="EPSG:4326")
zone_gdf
.explore()¶A GeoDataFrame isn't just a table — GeoPandas ships a one-liner, .explore(), that
renders an interactive Leaflet map straight from the geometry column. No folium
boilerplate needed for a first look:
zone_gdf.explore(tooltip=True, style_kwds={"color": "crimson", "weight": 2})
Change DAM at the top of this notebook to a different dam number — try
"UT00755" (Little Dell) — and re-run the cells above. Do the summary counts and
the map change the way you'd expect?
In Notebook 2 we'll use /risk/metrics/filters to search across all dams at
once using multi-criteria logic, instead of looking them up one at a time. In
Notebook 3 we'll go further with the mapping, layering in multiple infrastructure
types with custom styling. In Notebook 4 we'll come back to this same zone
geometry to check whether the API's precomputed counts can be trusted.