Notebook 1 — The Request Lifecycle¶

Geo Risk API — I-GUIDE

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.

In [ ]:
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())

Before we script anything: what is a REST API?¶

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:

Swagger UI showing the Risk endpoint group expanded, with GET /risk/summary open and "Try it out" active

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.

What can we even ask for?¶

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.

A quick note on .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.

In [ ]:
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:

In [ ]:
print(type(targets_response), type(targets_response["targets"]))  # dict, list

targets = targets_response["targets"]
print(f"{len(targets)} available targets:")
targets

Not every target means the same thing¶

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:

In [ ]:
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.

Quick counts: /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.

In [ ]:
summary = requests.get(
    f"{BASE_URL}/risk/summary",
    params={
        "damnumber": DAM,
        "targets": ["population", "hospitals", "railroads", "svi_tracts", "gap_status", "transportation"],
    },
    timeout=10,
).json()
summary

The canonical metrics table: /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.

In [ ]:
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.

In [ ]:
metrics = requests.get(
    f"{BASE_URL}/risk/metrics",
    params={"damnumber": DAM, "targets": "svi_score"},
    timeout=10,
).json()

df = pd.DataFrame(metrics["items"])
df

From JSON to GeoDataFrame¶

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).

In [ ]:
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

Bonus: instant interactive map with .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:

In [ ]:
zone_gdf.explore(tooltip=True, style_kwds={"color": "crimson", "weight": 2})

Exercise¶

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.