GDACS recalculates storm impact at every advisory (~6 hours). Each recalculation is an episode. This chapter explores what data lives inside episodes, how cumulative impact estimates evolve across issuances, and how early forecasts compare to final observations.
We use Typhoon KALMAEGI-25 (GDACS event 1001233) as a case study: a multi-country storm with 22 episodes spanning 6 days.
API responses were cached locally with scripts/cache_gdacs_episodes.py. To refresh, run:
uv run python scripts/cache_gdacs_episodes.py
Code
# -- Event detail ----------------------------------------------------------detail = json.loads((CACHE_DIR /"event_detail.json").read_text())props = detail["properties"]n_episodes =len(props["episodes"])# -- Episode metadata + resource URLs --------------------------------------episode_data = {}for i inrange(1, n_episodes +1): ep = json.loads((CACHE_DIR /f"episodes/{i}.json").read_text()) p = ep["properties"] episode_data[i] = {"alert_level": p["episodealertlevel"],"resources": p["impacts"][0]["resource"], }df_episodes = pd.DataFrame( [ {"episode": k, "alert_level": v["alert_level"]}for k, v in episode_data.items() ])# -- Impact JSON for latest episode (datum group exploration) --------------impact_json = json.loads( (CACHE_DIR /f"impacts/{n_episodes}.json").read_text())# -- Cumulative impact across all episodes ---------------------------------impact_rows = []for ep_id inrange(1, n_episodes +1): data = json.loads((CACHE_DIR /f"impacts/{ep_id}.json").read_text())for dg in data["datums"]:if dg["alias"] !="alert":continue total =0for d in dg["datum"]:for s in d["scalars"]["scalar"]:if s["name"] =="POP_AFFECTED": total +=int(float(s["value"])) impact_rows.append( {"episode": ep_id,"alert_level": episode_data[ep_id]["alert_level"],"n_admin_units": len(dg["datum"]),"pop_affected_34kt": total, } )breakdf_impact = pd.DataFrame(impact_rows)# -- Timeline data for selected episodes -----------------------------------tl_all = []for ep_id in SELECTED_EPISODES: tl_data = json.loads( (CACHE_DIR /f"timelines/{ep_id}.json").read_text() )for item in tl_data["channel"]["item"]: is_obs =str(item.get("actual", "")).strip().lower() =="true" tl_all.append( {"episode": ep_id,"valid_date": pd.to_datetime(item["advisory_datetime"]),"is_obs": is_obs,"pop39": int(item["pop39"]),"wind_speed": float(item["wind_speed"]), } )df_tl = pd.DataFrame(tl_all)issued_dates = ( df_tl[df_tl["is_obs"]] .groupby("episode")["valid_date"] .max() .to_dict())
NoteLive API calls (reference)
The cached data was originally fetched with these calls. Set eval: true to run them live instead of loading from cache.
Code
import requestsfrom src.datasets.gdacs import get_episode_detail, get_event_detaildetail = get_event_detail(EVENTID)props = detail["properties"]n_episodes =len(props["episodes"])# Fetch all episode details (1 API call each)episode_data = {}for i inrange(1, n_episodes +1): ep = get_episode_detail(EVENTID, i) p = ep["properties"] episode_data[i] = {"alert_level": p["episodealertlevel"],"resources": p["impacts"][0]["resource"], }# Fetch buffer39 impact per episode (1 API call each)for ep_id, ep in episode_data.items(): r = requests.get(ep["resources"]["buffer39"]) r.raise_for_status() data = r.json() # parse cumulative pop from data["datums"]# Fetch timeline per episode (1 API call each)for ep_id in [1, 5, 11, 16, 22]: r = requests.get(episode_data[ep_id]["resources"]["timeline"]) r.raise_for_status() items = r.json()["channel"]["item"] # per-advisory rows
3.1 API Hierarchy
GDACS exposes storm data at three levels: event, episode, and resource. Episodes sit above both impact and timeline – each episode has its own version of all four resources.
Each episode corresponds to one advisory issuance (~6-hourly). Alert level can change over the storm’s life:
Code
df_episodes
episode
alert_level
0
1
Orange
1
2
Orange
2
3
Orange
3
4
Orange
4
5
Orange
5
6
Orange
6
7
Red
7
8
Red
8
9
Red
9
10
Red
10
11
Red
11
12
Red
12
13
Red
13
14
Red
14
15
Red
15
16
Red
16
17
Orange
17
18
Orange
18
19
Red
19
20
Red
20
21
Red
21
22
Red
3.3 Anatomy of an Episode’s Resources
3.3.1 Impact resource (buffer39 / buffer74)
The impact resource contains nine datum groups, each providing a different slice of the affected area. The existing get_impact_by_country() function only reads the alert group and discards the rest.
Code
datum_summary = []for dg in impact_json["datums"]: alias = dg["alias"] count =len(dg.get("datum", [])) fields = []if count >0: scalars = dg["datum"][0].get("scalars", {}).get("scalar", []) fields = [s["name"] for s in scalars] datum_summary.append( {"alias": alias,"records": count,"sample_fields": ", ".join(fields[:5])+ ("..."iflen(fields) >5else""), } )pd.DataFrame(datum_summary)
alias
records
sample_fields
0
Population
1
SUMPOP50.0
1
Alert Parameters
1
SUMPOP50.0
2
Input Parameters
1
eventid, episodeid, latitude, longitude, shape
3
alert
30
OBJECTID, FIPS_ADMIN, GMI_ADMIN, ADMIN_NAME, F...
4
country
5
OBJECTID, FIPS_CNTRY, GMI_CNTRY, ISO_2DIGIT, I...
5
Airports
92
OBJECTID, NAME, IATA_CODE, ICAO_CODE, ADMIN1...
6
Airport
92
OBJECTID, NAME, IATA_CODE, ICAO_CODE, ADMIN1...
7
Nuclear Power Plant
1
OBJECTID, NPP_CLASS, ADM_NAME, SITE_NAME, SITE...
8
Hydro
21
OBJECTID, GRAND_ID, RES_NAME, DAM_NAME, ALT_NA...
9
City
54
OBJECTID, NAME, LANG, ADMIN1, ISLANDNAME...
10
Ports
94
OBJECTID, NAME, LOCODE, COUNTRY, TYPE...
The alert group contains 17 fields per admin unit. Key fields beyond what get_impact_by_country() currently captures:
Code
alert_group =next( dg for dg in impact_json["datums"] if dg["alias"] =="alert")scalars = alert_group["datum"][0]["scalars"]["scalar"]pd.DataFrame(scalars).rename(columns={"name": "field"})
field
value
valuetype
0
OBJECTID
2101
Int32
1
FIPS_ADMIN
VM54
String
2
GMI_ADMIN
VNM-BDN
String
3
ADMIN_NAME
Binh Dinh
String
4
FIPS_CNTRY
VM
String
5
GMI_CNTRY
VNM
String
6
CNTRY_NAME
Viet Nam
String
7
POP_ADMIN
1395557
Int32
8
TYPE_ENG
Province
String
9
TYPE_LOC
Tinh
String
10
SQKM
5745.83
Decimal
11
SQMI
2218.46
Decimal
12
COLORMAP
2
Int32
13
LABEL_FLAG
0
Int32
14
POP_AFFECTED
1486835
Int32
15
distance
0
Double
16
POP_ALERT_AFFECTED
1486835
Int32
3.3.2 Timeline resource
The timeline contains one row per advisory position. Each row carries an actual flag: True for observations, False for forecast positions appended after the latest observation.
Code
tl_data = json.loads((CACHE_DIR /"timelines/11.json").read_text())items = tl_data["channel"]["item"]tl_rows = []for item in items: is_obs =str(item.get("actual", "")).strip().lower() =="true" tl_rows.append( {"advisory": item["advisory_number"],"valid_date": item["advisory_datetime"],"source": "OBS"if is_obs else"FCST","wind_ms": round(float(item["wind_speed"]), 1),"pop39": f'{int(item["pop39"]):,}', } )print(f"Episode 11: {sum(1for r in tl_rows if r['source'] =='OBS')} obs + "f"{sum(1for r in tl_rows if r['source'] =='FCST')} forecast rows")pd.DataFrame(tl_rows)
Episode 11: 11 obs + 6 forecast rows
advisory
valid_date
source
wind_ms
pop39
0
1
01 Nov 2025 06:00
OBS
12.9
0
1
2
01 Nov 2025 12:00
OBS
15.4
0
2
3
01 Nov 2025 18:00
OBS
20.6
0
3
4
02 Nov 2025 00:00
OBS
23.1
0
4
5
02 Nov 2025 06:00
OBS
25.7
0
5
6
02 Nov 2025 12:00
OBS
28.3
0
6
7
02 Nov 2025 18:00
OBS
30.9
121,952
7
8
03 Nov 2025 00:00
OBS
36.0
2,687,163
8
9
03 Nov 2025 06:00
OBS
41.2
15,999,988
9
10
03 Nov 2025 12:00
OBS
46.3
25,630,688
10
11
03 Nov 2025 18:00
OBS
41.2
25,961,902
11
11
04 Nov 2025 06:00
FCST
36.0
10,205,634
12
11
04 Nov 2025 18:00
FCST
38.6
646,199
13
11
05 Nov 2025 06:00
FCST
43.7
0
14
11
05 Nov 2025 18:00
FCST
51.4
8,059,111
15
11
06 Nov 2025 18:00
FCST
46.3
13,290,540
16
11
07 Nov 2025 18:00
FCST
10.3
0
Episode 11 has 11 observations (the storm’s first 2.5 days) plus 6 forecast positions extending ~4 days ahead. The cumulative impact for this episode is computed over the union of all wind polygons across both observed and forecast positions.
3.4 Cumulative Impact Across Episodes
Because each episode unions all prior observations with a fresh forecast tail, the cumulative population exposure changes with every issuance – both because new areas are observed and because the forecast track shifts.
Figure 3.1: Cumulative population exposed at 34 kt grows as episodes accumulate observations and update forecasts. Bar colour reflects the episode’s alert level.
3.5 Forecast vs Observation
The same valid date (a point on the storm’s calendar) appears in multiple episodes. In an early episode it is a forecast; in a later episode it becomes an observation. This is the key value of episode data: you can see what GDACS was predicting at any point during the storm, and compare it to what actually happened.
3.5.1 Issued date vs valid date
The table below shows the same valid date (3 Nov 2025, KALMAEGI’s first major landfall) as seen from different episodes:
Each line below is one episode’s full timeline. Solid segments are observations; dashed segments are forecast. Where lines overlap on the x-axis, you can see how the forecast shifted between issuances.
Figure 3.2: Each episode’s view of population exposure over the storm’s life. Solid = observation, dashed = forecast. Early episodes (blue) are mostly forecast; late episodes (purple) are mostly observation.
3.6 Summary
Question
What to use
Current storm state (position, wind, instantaneous pop)
Event-level timeline (= latest episode timeline)
Cumulative pop by country / admin unit
Event-level impact (= latest episode impact)
How impact estimates evolved during the storm
Per-episode impact resources
What GDACS was forecasting at a specific point in time