flowchart LR
C(["client<br/>x-api-key: $PDC_API_KEY"])
C ==>|"GET /hazards?types=CYCLONE"| L["GeoJSON FeatureCollection<br/>1 active cyclone (Sinlaku)"]
C ==>|"GET /hazards/{uuid}"| D["Hazard detail JSON ~50 KB<br/>hazard, incident, exposure,<br/>impactGeometry, alertGeometry,<br/>features (1 Point + 1 Polygon),<br/>severity, category"]
C ==>|"GET /hazards/types"| T["array of {id, name}<br/>33 hazard types"]
C ==>|"GET /v3/api-docs"| O["OpenAPI 3.1.0 spec<br/>5 operational paths<br/>no response body schemas"]
C ==>|"GET /hazards (no params)"| ALL["GeoJSON FeatureCollection<br/>~570 features<br/>(active + recently-ended)"]
C -.->|"GET /hazards?status=ARCHIVED"| ALL
C -.->|"GET /hazards?status=ACTIVE"| ALL
C -.->|"GET /hazards?status=BOGUSVALUE"| ALL
C -.->|"GET /hazards?startedAfter=YYYY-MM-DD"| ALL
C -.->|"GET /hazards?updatedAfter=epoch"| ALL
C -.->|"GET /hazards/{uuid}/exposure"| X1["HTTP 500<br/>No static resource"]
C -.->|"GET /hazards/{uuid}/history"| X1
C -.->|"GET /incidents/{uuid}"| X1
C -.->|"GET /storage/{id}"| X1
C -.->|"GET /types (PDF wrong path)"| X1
classDef ok fill:#d9f5d9,stroke:#2a8a2a,color:#000
classDef ignored fill:#fff4e0,stroke:#d9a43a,color:#000
classDef bad fill:#f5d9d9,stroke:#a83232,color:#000
class L,D,T,O,ALL ok
class X1 bad
6 PDC Hazards API: Evaluation as a Cyclone Exposure Source
Pacific Disaster Center (PDC) operates a global hazard-monitoring API that surfaces active disasters of many kinds, including tropical cyclones. We evaluated it as a candidate third source of cyclone exposure data, alongside ADAM (covered in the ADAM, GDACS & CERF Allocation Overlap appendix) and GDACS (covered in Real-Time GDACS Exposure Retrieval).
The technical reference for the API (endpoints, response shapes, schema notes, and discrepancies between the published PDF and live behaviour) lives in docs/pdc_api.md in the project repository. This chapter focuses on what we found during evaluation and what we recommend.
6.1 How we explored it
We probed the live API on 2026-04-27 (API version 1.2.0) using the PDC-issued key (PDC_API_KEY). We started from PDC’s published PDC Hazards API v1.2.0 PDF and verified each documented behaviour against live responses. Several documented behaviours did not hold up. The full discrepancy table is in docs/pdc_api.md; the ones that mattered for our evaluation:
- The PDF documents a
/typesendpoint; the actual path is/hazards/types. The wrong path returns HTTP 500. - The PDF says
/hazardsreturns active hazards only and that there is no archive. Both claims are wrong: the response mixes active and recently-ended hazards on a rolling window of roughly the last 30 days, and there is no separate archive endpoint at all. - The
?status=,?startedAfter, and?updatedAfterquery parameters are silently ignored. Filtering by date or status is not possible through the API.
The OpenAPI specification at /v3/api-docs (auto-generated from the service’s controllers, so authoritative for the path surface) lists the entire endpoint surface as five operational paths: /hazards, /hazards/{uuid}, /hazards/types, plus /actuator/info and /actuator/health. There is no exposure subendpoint, no version history, no incidents endpoint. The detail object returned by /hazards/{uuid} is everything PDC exposes for a given hazard via this API.
The spec only formalizes the path surface, not response bodies: every operation declares application/json: {} with no schema attached, and components.schemas contains a single generic Link object. So the spec tells you what to call but not what each endpoint returns; response shapes had to be discovered live. The diagram below summarises what we actually probed, with the HTTP call on each arrow.
Three groups of arrows, three lessons. The solid arrows are the four endpoints that actually do something. The dotted yellow arrows show that ?status=, ?startedAfter, and ?updatedAfter are silently ignored: the server accepts any value (even nonsense) and returns the same payload as the unfiltered call. The dotted red arrows are plausible-looking subendpoints that the server does not route at all (including the path the PDF says is /types, which actually lives at /hazards/types).
Code
import json
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
CACHE = Path("_cache/08-pdc-evaluation")
pdc = json.loads((CACHE / "pdc_sinlaku.json").read_text())
gdacs_tl = pd.read_csv(
CACHE / "gdacs_timeline.csv", parse_dates=["advisory_datetime"]
)
def wind_color(ms):
"""Saffir-Simpson-ish color band for wind speed in m/s."""
if pd.isna(ms): return "#cccccc"
if ms < 17.5: return "#5ebaff" # TD
if ms < 25.0: return "#00faf4" # TS
if ms < 33.0: return "#ffffcc" # Cat 1
if ms < 43.0: return "#ffe775" # Cat 2
if ms < 50.0: return "#ffc140" # Cat 3
if ms < 58.0: return "#ff8f20" # Cat 4
return "#ff6060" # Cat 5The cells below load the cached snapshots populated by scripts/cache_pdc_sinlaku.py. Re-run that script to refresh against live data (note that PDC’s feed rotates a storm out essentially as soon as it ends — Sinlaku is long gone from the list view, though its detail is still retrievable by uuid).
6.2 Sinlaku in PDC vs GDACS
When we probed the API there was exactly one active cyclone in the PDC feed: Tropical Storm Sinlaku (UUID e621323a-1d6e-4b3c-9413-e72800dab5d4), in the Western Pacific. The same storm was also active in GDACS at the same time (event ID 1001270, name SINLAKU-26). Both systems’ responses for this storm are pinned in book/_cache/08-pdc-evaluation/ (tracked in git) and are regenerated by scripts/cache_pdc_sinlaku.py.
| Dimension | PDC | GDACS |
|---|---|---|
| Identity | UUID e621323a-..., name "Super Typhoon - Sinlaku (Response Support)" |
event ID 1001270, name "SINLAKU-26" |
| Lifecycle coverage | Single Point at 15.17°N, 145.58°E (near Guam), recorded 2026-04-20 16:30 UTC. Not refreshed since. | 44 actual advisories at 6-hour cadence from Apr 9 to Apr 19, plus 2 forecast advisories. Tracked from formation to dissipation. |
| Storm-ended status | hazard.endedAt = 32503679999 (decodes to 2999-12-31T23:59:59 UTC, used as sentinel for “still active”) |
is_current = False, todate = 2026-04-19T18:00 (storm ended Apr 19) |
| Severity (peak) | severity = WARNING, category = RESPONSE |
alert_level = Red, severity_kmh = 287 (peak forecast km/h) |
| Aggregate population exposed | 0 (exposure.data.population.total.value) |
(cumulative inside wind buffers) |
| Per-country exposure | totalByCountry: [] (empty) |
Guam 166,535 (within 39 kt buffer); Northern Mariana Islands 41,579 (within both 39 kt and 74 kt) |
| Source attribution | incident.snapshot.properties.map.sourceName = "PDC Manual Hazard". Empty sourceUrl and rawMessage. |
properties.source = "JTWC" (Joint Typhoon Warning Center) |
Two things explain what we see in the PDC entry. First, both systems agree the storm itself is over: GDACS marks is_current = False with todate = 2026-04-19T18:00, and JTWC issued no actual advisories after Apr 19 (the final advisory wind was ~25.7 kt, near the dissipation threshold). PDC entered its record on Apr 20 16:30 UTC, after the storm had effectively ended.
Second, the PDC entry is a response-coordination snapshot, not a meteorological track. The naming convention makes this explicit: the hazard name is "Super Typhoon - Sinlaku (Response Support)", with category = RESPONSE. The position chosen (near Guam) corresponds to the storm’s peak-intensity area where humanitarian impact would be focused. The sourceName = "PDC Manual Hazard" value (found at incident.snapshot.properties.map.sourceName in the detail JSON, with empty sourceUrl and rawMessage siblings) confirms an analyst typed in the record manually rather than the system ingesting an automated bulletin from JTWC, NHC, or another official forecast center. This is consistent with a response-coordination interpretation: an analyst created a coordination record after the meteorological event, not a real-time tracker.
The PDF documents category = EVENT as the typical value for a hazard and lists features as the place where a cyclone track would live. Sinlaku has category = RESPONSE and only a single Point in features. We do not yet know whether category = EVENT cyclones fed automatically from a forecast center would carry per-advisory tracks or populated exposure. Sinlaku is a manual response-support entry, so we cannot generalise from it.
The PDC exposure schema is rich on paper. Where it does run (verified on the Puerto Rico flood 9175d060-4f6b-49f9-9335-950dfbcb0caa, documented in docs/pdc_api.md) it provides per-country breakdowns with ISO3 codes, granular age bands, vulnerability classes, and capital exposure split by schools and hospitals. The shape joins cleanly to our existing GDACS schema on (country, eventid). None of that helps for Sinlaku where every population value is zero.
Code
actual = gdacs_tl[gdacs_tl["actual"].astype(str).str.lower() == "true"]
forecast = gdacs_tl[gdacs_tl["actual"].astype(str).str.lower() != "true"]
fig = go.Figure()
fig.add_trace(go.Scattermap(
lat=actual["latitude"], lon=actual["longitude"],
mode="lines", line={"width": 2, "color": "#666"},
name="GDACS actual track", hoverinfo="skip",
))
for _, r in actual.iterrows():
fig.add_trace(go.Scattermap(
lat=[r["latitude"]], lon=[r["longitude"]],
mode="markers",
marker={"size": 9, "color": wind_color(r["wind_speed"])},
text=(
f"GDACS adv {int(r['advisory_number'])}<br>"
f"{r['advisory_datetime']}<br>"
f"wind {r['wind_speed']:.1f} m/s "
f"({r['wind_speed']*3.6:.0f} km/h)<br>"
f"pop39 {r['pop39']:,.0f}, pop74 {r['pop74']:,.0f}"
),
hoverinfo="text", showlegend=False,
))
if len(forecast):
fig.add_trace(go.Scattermap(
lat=forecast["latitude"], lon=forecast["longitude"],
mode="markers+lines",
marker={"size": 8, "color": "#888", "opacity": 0.5},
line={"width": 1, "color": "#aaa"},
name="GDACS forecast",
text=forecast["advisory_datetime"].astype(str),
hoverinfo="text",
))
fig.add_trace(go.Scattermap(
lat=[pdc["latitude"]], lon=[pdc["longitude"]],
mode="markers+text",
marker={"size": 22, "color": "red", "symbol": "star"},
text=["PDC (single point)"], textposition="top right",
textfont={"color": "red", "size": 13},
name="PDC", hoverinfo="text",
hovertext=f"PDC point<br>{pdc['latitude']:.3f}, {pdc['longitude']:.3f}",
))
fig.update_layout(
map={
"style": "carto-positron",
"center": {"lat": actual["latitude"].mean(),
"lon": actual["longitude"].mean()},
"zoom": 3,
},
height=550, margin={"r": 0, "t": 10, "l": 0, "b": 0},
legend={"yanchor": "top", "y": 0.99, "xanchor": "left", "x": 0.01},
)
figThe visual gap between the red star and the upper end of the GDACS track is the entire post-Apr-20 evolution of the storm. Neither system captured it, because both effectively stopped: GDACS issued no further actual advisories after Apr 19 18:00, and PDC’s single-point entry was made on Apr 20 16:30 with no subsequent refresh.
6.2.1 The three geometries
The detail object exposes three GeoJSON containers: impactGeometry, alertGeometry, and features. For Sinlaku:
| Container | Contents | Properties |
|---|---|---|
impactGeometry.geoJson |
One Polygon, 347 vertices | severity = MODERATE, exposureDescription = "Moderate Damage Expected", featureType = SMART_ALERT, exposureLevel = 1 |
alertGeometry.geoJson |
One Polygon, 347 vertices, distinct shape from impactGeometry |
None (properties: null) |
features.geoJson |
One Point at the incident location, plus one Polygon with coordinates identical to impactGeometry |
Point: full incident metadata. Polygon: severity = MODERATE, featureType = SMART_ALERT (subset of impactGeometry props) |
Two takeaways:
impactGeometryandalertGeometryare different shapes, consistent with the PDF description ofalertGeometryas “broader than impact”. Both happen to have 347 vertices but the coordinates differ.- The polygon inside
features.geoJsonduplicatesimpactGeometry(same coordinates, slightly fewer properties). For Sinlaku,featuresis not a cyclone track as the PDF suggests with “(e.g. cyclone track)”; it is an incident point plus a copy of the impact polygon. Whetherfeaturescarries actual track points for an automatically-ingested cyclone (one fed by JTWC, NHC, or another forecast center) is one of the questions we need PDC to answer.
Code
def poly_coords(feature_collection):
polys = [
f for f in feature_collection["features"]
if f["geometry"]["type"] == "Polygon"
]
return polys[0]["geometry"]["coordinates"][0] if polys else []
impact_xy = poly_coords(pdc["impactGeometry"]["geoJson"])
alert_xy = poly_coords(pdc["alertGeometry"]["geoJson"])
actual = gdacs_tl[gdacs_tl["actual"].astype(str).str.lower() == "true"]
forecast = gdacs_tl[gdacs_tl["actual"].astype(str).str.lower() != "true"]
fig = go.Figure()
# GDACS actual track — single bold line, no per-advisory intensity markers
fig.add_trace(go.Scattermap(
lat=actual["latitude"], lon=actual["longitude"],
mode="lines",
line={"width": 3, "color": "#6a3d9a"},
name="GDACS actual track (Apr 9 to Apr 19)",
hoverinfo="skip",
))
if len(forecast):
fig.add_trace(go.Scattermap(
lat=forecast["latitude"], lon=forecast["longitude"],
mode="markers+lines",
marker={"size": 7, "color": "#b497d6"},
line={"width": 2, "color": "#b497d6"},
name="GDACS forecast (2 points)",
text=forecast["advisory_datetime"].astype(str),
hoverinfo="text",
))
# PDC polygons + incident Point
fig.add_trace(go.Scattermap(
lat=[c[1] for c in impact_xy],
lon=[c[0] for c in impact_xy],
mode="lines", fill="toself",
fillcolor="rgba(33, 102, 172, 0.30)",
line={"width": 1.5, "color": "#2166ac"},
name="PDC impactGeometry (= polygon in features.geoJson)",
))
fig.add_trace(go.Scattermap(
lat=[c[1] for c in alert_xy],
lon=[c[0] for c in alert_xy],
mode="lines",
line={"width": 2, "color": "#d6604d"},
name="PDC alertGeometry (broader, distinct shape)",
))
fig.add_trace(go.Scattermap(
lat=[pdc["latitude"]], lon=[pdc["longitude"]],
mode="markers+text",
marker={"size": 18, "color": "red", "symbol": "star"},
text=["PDC incident Point"], textposition="top right",
textfont={"color": "red", "size": 12},
name="PDC incident Point (from features.geoJson)",
))
fig.update_layout(
map={
"style": "carto-positron",
"center": {"lat": actual["latitude"].mean(),
"lon": actual["longitude"].mean()},
"zoom": 3,
},
height=550, margin={"r": 0, "t": 10, "l": 0, "b": 0},
legend={"yanchor": "top", "y": 0.99, "xanchor": "left", "x": 0.01},
)
fig6.3 Structural limitations
Three properties of the API would shape any pipeline built against it, independent of the Sinlaku-specific findings:
- No archive.
/hazardsreturns only the hazards PDC still considers open. Thestatusquery parameter is ignored, so there is no way to ask for older events. (This chapter originally estimated a ~30-day post-end tail; re-measurement in Section 7.8.1 shows there is effectively none.) A back-catalogue would have to be built locally by polling daily and accumulating. That pattern matches the GDACS daily monitor (src/gdacs_monitor_email.py), not the GDACS historical exposure pipeline. - No incremental polling primitive. Date filters are silently ignored, so each poll fetches the full feed (a single ~340 KB JSON document containing roughly 570 features at present). Deduplication has to happen client-side using
(uuid, updatedAt). - No pagination. If the rolling window grows substantially (we observed this happening once: a cohort of 86 year-long flood events all closed in a single 48-hour stretch), there is no documented way to page through the response.
Full evidence and the verified retention model are in docs/pdc_api.md.
6.4 Open questions
A single manual-source observation does not let us settle the points below. They are the things we would need to verify before integrating PDC, and they are the substance of an outreach to PDC’s team that is in flight.
Manual vs automated ingestion for cyclones. Sinlaku has
incident.snapshot.properties.map.sourceName = "PDC Manual Hazard"with emptysourceUrlandrawMessage. Whether tropical cyclones are normally ingested automatically from JTWC, NHC, or another official forecast center, or whether manual entry is the standard path, is not visible from this one record.Per-advisory tracks for cyclones. The PDF describes
featuresas a GeoJSON FeatureCollection with “(e.g. cyclone track)”. For Sinlaku,features.geoJsoncontains only one Point and one Polygon (the polygon being a duplicate ofimpactGeometry). Whether an automatically-ingested cyclone would carry a multi-point track infeatures, and what schema each track point would use (timestamp, wind speed, pressure, radii), is unknown.What triggers the exposure compute. Sinlaku’s
exposure.datais all zeros. The Puerto Rico flood9175d060-...returns populatedtotalByCountrywith age bands and vulnerability classes (documented indocs/pdc_api.md). Whether the compute is gated on source flavor (manual vs automated),categoryvalue, severity, or something else, is not documented.category = RESPONSEsemantics. Sinlaku hascategory = "RESPONSE"and the name suffix(Response Support). The PDF describescategory = EVENTas the typical value. WhatRESPONSEmeans operationally, and whether it indicates a different ingestion path or different lifecycle, is not documented.endedAtlifecycle. Sinlaku hashazard.endedAt = 32503679999(=2999-12-31T23:59:59 UTC, a sentinel) and remains in the active feed, although per JTWC the storm dissipated around 2026-04-19. The conditions under whichendedAtis set to a real timestamp, and the lifecycle for moving a hazard out of the active feed, are not documented.Historical access.
/hazardsreturns only hazards PDC still considers open, with no documented access path for older records. For analytical work covering past cyclone seasons (CERF coverage starts 2006) the rolling window is not sufficient.Filter parameters and pagination. The
?status=,?startedAfter,?updatedAfter, and?endedAfterparameters return HTTP 200 but appear to be silently ignored (the response is byte-identical regardless of the value passed). Whether they are intended to filter and merely broken, or were never meant to, is unclear. There is also no documented pagination mechanism.Joining to IBTrACS. For an automatically-ingested cyclone, the location of any JTWC ATCF ID or NHC identifier within the response is unknown, which affects whether PDC events can be joined to IBTrACS storm IDs.
6.5 Recommendation
Don’t integrate PDC into the cyclone exposure pipeline at this stage. The structural reason is that PDC’s only observable cyclone is a single response-coordination snapshot: one Point, recorded after the storm ended, no per-advisory data, zero exposure values. Even if a future automatically-ingested cyclone has populated exposure and a real track in features, the rolling-window retention model (no historical backfill) rules out using PDC for analytical work going back to CERF’s 2006 baseline.
For real-time monitoring, GDACS already provides what we need: per-advisory tracking through the storm life cycle, cumulative country-level exposure, and a clean link to JTWC. PDC may turn out to add value as a humanitarian-response-coordination layer (which is what the RESPONSE category appears to encode), but that is a different layer of the pipeline from exposure quantification.
The conservative re-evaluation point is the next non-manual cyclone in the feed. The answers to the open questions above should clarify whether such a storm is something we can expect to see, and what it would carry.
That re-evaluation trigger fired during the 2026 season. PDC Revisited: The 2026 Season re-runs this comparison against automatically-ingested cyclones and revises the recommendation above.
In short: open questions 1 and 8 are resolved, and the “manual entry with zero exposure” finding does not generalise — automated cyclones carry full forecast tracks, standard quadrant wind radii, per-country exposure, and an ATCF ID that joins exactly to IBTrACS. The paragraph above claiming GDACS “already provides what we need” for real-time monitoring also needs qualifying. Conversely the no-archive limitation holds and is more severe than described here: PDC carries no track history even for live storms.
Read chapter 11 before acting on this chapter’s recommendation.