Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
ff17c00
tropical cyclone set up
jmargutt Jun 29, 2026
cd93c3c
feat(tropical-cyclone): register hazard scaffolding
Jul 3, 2026
45e6738
Tropical-cyclone: Add constants and forecast orchestration skeleto. A…
Jul 3, 2026
d86f38e
fix wind-speed conversion and bounding-box design. MIN_SEVERITY_MS is…
Jul 3, 2026
84f2920
Merge remote-tracking branch 'origin/main' into data/tropical-cyclone…
Jul 8, 2026
d62929c
fix(tropical-cyclone): rename Layer to LayerName after main merge
Jul 8, 2026
e217551
feat(tropical-cyclone): register hazard type and required enum. Add t…
duruena Jul 8, 2026
92f1023
fix(tropical-cyclone): fix two pyright errors found by python-knip.py
duruena Jul 10, 2026
6c011d7
extract_forecast.py wind-speed extraction, verified against real GEFS…
duruena Jul 10, 2026
7164bff
extract_track.py track extraction, verified against real data. Clean …
duruena Jul 10, 2026
e0bab8b
lock-in aggregation order and monitoring constants
duruena Jul 10, 2026
c6ab2fc
lock footprint raster as a precautionary per-cell-max envelope
duruena Jul 10, 2026
6246100
feat(tropical-cyclone): determine_alerts.py against the locked aggreg…
duruena Jul 10, 2026
81c8585
land compute_wind_extent.py, the precautionary envelope max of medians
duruena Jul 10, 2026
6714d38
determine_exposure.py, and close out the placeholder swaps
duruena Jul 10, 2026
e63faad
Merge remote-tracking branch 'origin/main' into data/tropical-cyclone…
duruena Jul 13, 2026
d8d9f1b
Typo fix
duruena Jul 14, 2026
f9ff259
Removin idx for the regex to avoid feeding the wrong file.
duruena Jul 14, 2026
220a1ce
Missing mask created to spread Nodata downstream
duruena Jul 14, 2026
5e44316
chore: updated constants
duruena Jul 14, 2026
caca7c4
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 14, 2026
f3269c8
style: prettier format
duruena Jul 14, 2026
ecddbe0
fix: 2 enumerators were declared
duruena Jul 14, 2026
22f5335
feat(tropical-cyclone): fetch alert configs from the API and bucket w…
duruena Jul 14, 2026
4182ce0
feat(tropical-cyclone): load local GEFS test fixtures and speed up ad…
duruena Jul 14, 2026
f57dac2
test(tropical-cyclone): add unit and infra-integration test coverage
duruena Jul 14, 2026
5168683
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 14, 2026
505edb9
style: Readme clarifies how to name a hazard in the .yaml file
duruena Jul 15, 2026
404ccd7
test: add focused tropical cyclone track tests
Copilot Jul 15, 2026
4450250
fix(tropical-cyclone): guard against mixed forecast cycles (copilot c…
duruena Jul 15, 2026
1838bcd
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 15, 2026
8aec771
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 20, 2026
3931227
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 21, 2026
40add49
docs(tropical-cyclone): document local run workaround for source_targ…
duruena Jul 21, 2026
5b9c738
refactor(tropical-cyclone): address PR review quick fixes
duruena Jul 23, 2026
229ef1b
refactor(tropical-cyclone): rename functions for clarity per PR review
duruena Jul 24, 2026
128c66c
fix(tropical-cyclone): exclude nodata cells correctly in population e…
duruena Jul 24, 2026
8f3d8dc
fix(tropical-cyclone): Guard missing population input and mark tempor…
duruena Jul 24, 2026
86f6b1c
update constants for ECMWF data source
jmargutt Jul 28, 2026
d087b9e
update hazard-logic for ECMWF
jmargutt Jul 28, 2026
7392c49
update tests for ECMWF
jmargutt Jul 28, 2026
683b836
helper script to download ECMWF test data
jmargutt Jul 28, 2026
d615c71
add eccodes for ECMWF
jmargutt Jul 28, 2026
fd9a17e
update readme
jmargutt Jul 28, 2026
1d0bdc7
bug fix ECMWF lead_time_step switches at 144h
jmargutt Jul 28, 2026
ac21a7e
fix(pipelines): decode population rasters in chunks to bound peak memory
duruena Jul 29, 2026
b646bc1
fix(tropical-cyclone): gate alerts on storm presence and union the al…
duruena Jul 29, 2026
43c0064
chore: pin the Python pre-commit hook to the project's own tool versions
duruena Jul 29, 2026
61267d2
fix(tropical-cyclone): forecast PHL from GEFS rather than ECMWF
duruena Jul 29, 2026
9140581
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 29, 2026
70fd167
fix(tropical-cyclone):stop reporting a stale storm position as the al…
duruena Jul 30, 2026
48e9e38
fix(tropical-cyclone): recover admin areas smaller than one wind cel
duruena Jul 30, 2026
9f07099
Merge branch 'main' into data/tropical-cyclone-hazard-logic
duruena Jul 30, 2026
7e6e0e3
feat(tropical-cyclone): alert per storm with a stable per-storm event…
duruena Jul 30, 2026
31151b5
doc(tropical-cyclone): Adding documentation for running mock scenarios
duruena Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test_pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
run: |
curl --fail --silent --show-error \
--request POST \
--url "http://localhost:4000/api/reset?countryCodes=UGA&resetIdentifier=ci-pipeline-test" \
--url "http://localhost:4000/api/reset?countryCodes=UGA,PHL&resetIdentifier=ci-pipeline-test" \
Comment thread
duruena marked this conversation as resolved.
--header "Content-Type: application/json" \
--data '{"secret":"fill_in_secret"}'

Expand Down
6 changes: 5 additions & 1 deletion .lintstagedrc.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
// See: https://github.com/lint-staged/lint-staged#using-js-configuration-files
module.exports = {
'*.{md,json,yml,scss}': 'prettier --write',
'*.py': ['uvx ufmt format', 'uvx ruff check'],
// Run the versions pinned in data/uv.lock.
'*.py': [
'uv run --project data ufmt format',
'uv run --project data ruff check',
],
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear to me what the point of this file is. I get it downloads data, but what is subsequently done with that data. Also, this is in the seed_data_management folder, which implies its output is pushed to the seed data repo. Is it? And if so, is anything subsequently fetching this from the seed data repo again? Or was this part of Jacopo's additions? (Which I specifically didn't review yet FYI)

Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
"""Download a small slice of real ECMWF open-data IFS forecasts into the tropical-cyclone
`bronze/` fixture directory, for locally testing the ECMWF branch of the TC pipeline
(`extract_forecast.py` / `extract_track.py`).

The ECMWF stream names and native wind cadence used to build the download are taken from
`pipelines.tropical_cyclone.constants`. What it downloads does not depend on any country: ECMWF
open-data files are global, and the pipeline slices them to a country's bounds at read time. The
script only warns if no country is currently configured for `ForecastSource.ECMWF` (none is by
default - see COUNTRY_CONFIGS), since the fixtures it writes are then downloaded but unused until
a country is switched over.

What it writes (matching the layouts `forecast.py`'s ECMWF placeholder loaders and the
`extract_*` parsers expect):
pipelines/tropical_cyclone/bronze/ecmwf_wind/<YYYYMMDD>/<HH>z/ifs/0p25/<stream>/
<YYYYMMDD><HH>0000-<step>h-<stream>-<fc|ef>.grib2
pipelines/tropical_cyclone/bronze/ecmwf_track/<YYYYMMDD>/<HH>z/ifs/0p25/<stream>/
<YYYYMMDD><HH>0000-<step>h-<stream>-tf.bufr

Wind is fetched efficiently: only the 10 m u/v components (and only a few ensemble members) are
pulled, using each GRIB2 file's `.index` sidecar to issue HTTP byte-range requests - so a per-step
enfo file with all 50 members is never downloaded in full. Track BUFR files are small and fetched
whole; they only exist when ECMWF is actively tracking a cyclone, so a 404 there is not an error.

`bronze/` is gitignored - these files are a local-testing convenience, never committed.

Usage:
cd data
uv run python data_management/seed_data_management/fetch_ecmwf_tropical_cyclone_test_data.py
# options:
uv run python data_management/seed_data_management/fetch_ecmwf_tropical_cyclone_test_data.py \
--members 10 --steps 0,3,6,9 --cycle 2026072800
# full 7-day window (0..144h by 3h, then 150..168h by 6h) - pair with a small --members:
uv run python data_management/seed_data_management/fetch_ecmwf_tropical_cyclone_test_data.py \
--full-window --members 5
"""

from __future__ import annotations

import argparse
import json
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path

import requests
from pipelines.infra.data_types.enums import ForecastSource
from pipelines.tropical_cyclone import constants

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)

# ECMWF open-data forecast root (mirrored on AWS/Azure/GCS); only ~4 days of history are kept.
ECMWF_OPEN_DATA_ROOT = "https://data.ecmwf.int/forecasts"

# 10 m wind components we need; the parser combines them into a sustained wind speed.
WIND_PARAMS = ("10u", "10v")

# open-data product `type` per stream, for the wind (GRIB2) and track (BUFR) products.
WIND_TYPE_BY_STREAM = {
constants.ECMWF_STREAM_CONTROL: "fc", # oper -> deterministic control forecast
constants.ECMWF_STREAM_PERTURBED: "ef", # enfo -> perturbed ensemble members
}
TRACK_TYPE = "tf" # tropical-cyclone track BUFR, same token for oper and enfo

# Bronze fixture roots (gitignored), resolved next to the tropical_cyclone package.
_BRONZE_DIR = Path(constants.__file__).resolve().parent / "bronze"
WIND_ROOT = _BRONZE_DIR / "ecmwf_wind"
TRACK_ROOT = _BRONZE_DIR / "ecmwf_track"

# How many recent 6-hourly cycles to probe before giving up finding an available one.
_MAX_CYCLE_LOOKBACK = 8

# Full forecast window (7 days = 168h), matching the TC alert config's seeded "lead-time-spectrum".
# ECMWF ENS 0p25 is 3-hourly out to 144h and 6-hourly beyond, so --full-window switches cadence
# there (there are no 3-hourly steps past 144h to fetch).
_FULL_WINDOW_MAX_HOURS = 168
_ECMWF_THREE_HOURLY_MAX_HOURS = 144


def _cycle_relative_dir(cycle: datetime) -> str:
"""The `<YYYYMMDD>/<HH>z/ifs/0p25` path segment shared by every open-data file in a run."""
return f"{cycle:%Y%m%d}/{cycle:%H}z/ifs/0p25"


def _file_stem(cycle: datetime, step: int, stream: str, product_type: str) -> str:
return f"{cycle:%Y%m%d}{cycle:%H}0000-{step}h-{stream}-{product_type}"


def _open_data_url(
cycle: datetime, step: int, stream: str, product_type: str, extension: str
) -> str:
"""Build an ECMWF open-data file URL. The GRIB2 data lives at `<stem>.grib2`, its byte-range
index at `<stem>.index` (same stem, different extension - not `<stem>.grib2.index`), and track
BUFR at `<stem>.bufr`."""
stem = _file_stem(cycle, step, stream, product_type)
return f"{ECMWF_OPEN_DATA_ROOT}/{_cycle_relative_dir(cycle)}/{stream}/{stem}.{extension}"


def _bronze_path(root: Path, url: str) -> Path:
"""Mirror an open-data file's `<date>/<hh>z/ifs/0p25/<stream>/<file>` tail under a bronze root,
so the pipeline's ECMWF path parsers recognise it."""
tail = url.split(f"{ECMWF_OPEN_DATA_ROOT}/", 1)[1]
return root.joinpath(*tail.split("/"))


def _resolve_cycle(session: requests.Session, override: str | None) -> datetime:
"""Return the ECMWF cycle to download: an explicit --cycle (YYYYMMDDHH) if given, otherwise the
most recent 00/06/12/18 UTC cycle whose enfo wind is already published."""
if override is not None:
return datetime.strptime(override, "%Y%m%d%H").replace(tzinfo=timezone.utc)

now = datetime.now(timezone.utc)
candidate = now.replace(hour=(now.hour // 6) * 6, minute=0, second=0, microsecond=0)
for _ in range(_MAX_CYCLE_LOOKBACK):
index_url = _open_data_url(
candidate, 0, constants.ECMWF_STREAM_PERTURBED, "ef", "index"
)
if session.get(index_url, timeout=60).status_code == 200:
return candidate
candidate -= timedelta(hours=6)
raise RuntimeError(
f"No published ECMWF enfo cycle found in the last {_MAX_CYCLE_LOOKBACK} attempts "
f"(searched back from {now:%Y-%m-%d %H}:00 UTC)"
)


def _selected_index_ranges(
index_text: str, stream: str, member_numbers: set[int]
) -> list[tuple[int, int]]:
"""From a GRIB2 `.index` (one JSON object per message), pick the (offset, length) byte ranges
for the 10 m wind params - restricted to `member_numbers` for the perturbed `enfo` stream (the
control `oper` file has a single member with no `number` key)."""
ranges: list[tuple[int, int]] = []
for line in index_text.splitlines():
if not line.strip():
continue
entry = json.loads(line)
if entry.get("param") not in WIND_PARAMS:
continue
if stream == constants.ECMWF_STREAM_PERTURBED:
number = entry.get("number")
if number is None or int(number) not in member_numbers:
continue
ranges.append((int(entry["_offset"]), int(entry["_length"])))
return ranges


def _download_wind_file(
session: requests.Session,
cycle: datetime,
step: int,
stream: str,
member_numbers: set[int],
) -> bool:
"""Fetch only the needed 10 m wind messages of one per-step GRIB2 file via byte-range requests,
concatenating them into a valid GRIB2 in the bronze tree. Returns whether a file was written.
"""
product_type = WIND_TYPE_BY_STREAM[stream]
grib_url = _open_data_url(cycle, step, stream, product_type, "grib2")
index_url = _open_data_url(cycle, step, stream, product_type, "index")
index_response = session.get(index_url, timeout=60)
if index_response.status_code != 200:
logger.warning(f"No index for {grib_url} (status {index_response.status_code})")
return False

ranges = _selected_index_ranges(index_response.text, stream, member_numbers)
if not ranges:
logger.warning(f"No matching wind messages in index for {grib_url}")
return False

out_path = _bronze_path(WIND_ROOT, grib_url)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "wb") as grib_file:
for offset, length in sorted(ranges):
headers = {"Range": f"bytes={offset}-{offset + length - 1}"}
message = session.get(grib_url, headers=headers, timeout=60)
message.raise_for_status()
grib_file.write(message.content)

logger.info(f"Wrote {out_path} ({len(ranges)} messages)")
return True


def _download_track_file(
session: requests.Session, cycle: datetime, step: int, stream: str
) -> bool:
"""Fetch a whole track BUFR file (small) into the bronze tree. Returns whether a file was
written; a missing file (no active cyclone) is expected and not an error."""
track_url = _open_data_url(cycle, step, stream, TRACK_TYPE, "bufr")
response = session.get(track_url, timeout=60)
if response.status_code == 404:
logger.info(f"No track file at {track_url} (no active cyclone this run?)")
return False
response.raise_for_status()

out_path = _bronze_path(TRACK_ROOT, track_url)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(response.content)
logger.info(f"Wrote {out_path} ({len(response.content)} bytes)")
return True


def _parse_steps(raw: str) -> list[int]:
return [int(part) for part in raw.split(",") if part.strip()]


def _full_window_steps(cycle: datetime) -> list[int]:
"""Lead-time steps spanning the forecast window at ECMWF's native cadence: 3-hourly
(ECMWF_NATIVE_LEAD_TIME_STEP_HOURS) out to 144h, then 6-hourly. Per ECMWF docs the ENS wind
horizon is 360h at 00/12 UTC but only 144h at 06/18 UTC, so the window caps at 168h for 00/12
and at 144h (no 6-hourly tail) for 06/18."""
native_step = constants.ECMWF_NATIVE_LEAD_TIME_STEP_HOURS
max_hours = (
_FULL_WINDOW_MAX_HOURS
if cycle.hour in (0, 12)
else _ECMWF_THREE_HOURLY_MAX_HOURS
)
three_hourly = list(range(0, _ECMWF_THREE_HOURLY_MAX_HOURS + 1, native_step))
six_hourly = list(range(_ECMWF_THREE_HOURLY_MAX_HOURS + 6, max_hours + 1, 6))
return three_hourly + six_hourly


def _track_horizon_step(cycle: datetime) -> int:
"""ECMWF publishes cyclone tracks out to 360h for the 00/12 UTC runs and 144h for 06/18 UTC."""
return 360 if cycle.hour in (0, 12) else 144


def main() -> None:
"""Resolve the target cycle and download the wind and track fixtures into `bronze/`."""
native_step = constants.ECMWF_NATIVE_LEAD_TIME_STEP_HOURS
default_steps = [0, native_step, 2 * native_step]

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--members",
type=int,
default=5,
help="Number of perturbed enfo members to fetch (1..N). Default 5.",
)
step_group = parser.add_mutually_exclusive_group()
step_group.add_argument(
"--steps",
type=_parse_steps,
default=default_steps,
help="Comma-separated wind lead hours to fetch. "
f"Default {','.join(map(str, default_steps))}.",
)
step_group.add_argument(
"--full-window",
action="store_true",
help="Fetch the full forecast window at ECMWF's native cadence instead of --steps: "
"0..144h by 3h then 150..168h by 6h for 00/12 UTC cycles, capped at 144h for 06/18 UTC. "
"Large at high member counts - pair with a small --members.",
)
parser.add_argument(
"--cycle",
type=str,
default=None,
help="Force a specific cycle as YYYYMMDDHH (UTC). Default: latest published.",
)
args = parser.parse_args()

# Warn rather than exit: what gets downloaded is country-independent (ECMWF open-data files
# are global), so the fetch is still valid - the fixtures just sit unused until some country
# is pointed at ECMWF.
if not any(
config.forecast_source is ForecastSource.ECMWF
for config in constants.COUNTRY_CONFIGS.values()
):
logger.warning(
"No country is currently configured for ECMWF (see COUNTRY_CONFIGS in "
"tropical_cyclone/constants.py) - fetching anyway, but nothing will read these "
"fixtures until one is switched over."
)

member_numbers = set(range(1, args.members + 1))
session = requests.Session()

cycle = _resolve_cycle(session, args.cycle)
steps = _full_window_steps(cycle) if args.full_window else args.steps
logger.info(
f"Fetching ECMWF test data - cycle {cycle:%Y-%m-%d %H}:00 UTC, "
f"wind steps {steps}, enfo members 1..{args.members}"
)

wind_files = 0
for step in steps:
for stream in (
constants.ECMWF_STREAM_CONTROL,
constants.ECMWF_STREAM_PERTURBED,
):
if _download_wind_file(session, cycle, step, stream, member_numbers):
wind_files += 1

track_step = _track_horizon_step(cycle)
track_files = 0
for stream in (constants.ECMWF_STREAM_CONTROL, constants.ECMWF_STREAM_PERTURBED):
if _download_track_file(session, cycle, track_step, stream):
track_files += 1

logger.info(
f"Done: {wind_files} wind file(s) under {WIND_ROOT}, "
f"{track_files} track file(s) under {TRACK_ROOT}."
)


if __name__ == "__main__":
main()
8 changes: 6 additions & 2 deletions data/pipelines/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Most of the fields in the config file are mapped to enums. You can see the allow
To handle new configs, see the sections below.

```
hazard_type # HazardType enum (e.g. "floods", "drought")
hazard_type # HazardType enum (e.g. "floods", "drought", "tropicalCyclone")

_data_sources: &data_sources # Anchor: data sources for this hazard
- source # DataSource enum showing where to fetch this data
Expand All @@ -163,13 +163,17 @@ countries:
data_sources: *data_sources # Required; reference the anchor or override per country
```

`hazard_type` must match a `HazardType` enum value exactly, including case. Enum values for multi-word hazards follow camelCase (e.g. `tropicalCyclone`), so write them in the config YAML the same way — not all-lowercase, not hyphenated.

### Adding a new hazard type

1. Create a new folder: `<hazard_type>/`
2. Copy `infra/template_forecast.py` to `<hazard_type>/forecast.py`
3. Implement the hazard-specific logic (replace placeholders marked with `<...>`)
4. Register the function in `infra/run_forecasts.py` (`HAZARD_FUNCTIONS`)
5. Add a config YAML in `infra/configs/<hazard_type>.yaml`
5. Add a config YAML in `infra/configs/<hazard_type>.yaml`, with `hazard_type` written in
camelCase to match its `HazardType` enum value exactly (e.g. `tropicalCyclone`, not
`tropical-cyclone` or `tropicalcyclone`)

### Adding a new data source

Expand Down
6 changes: 3 additions & 3 deletions data/pipelines/flood/compute_flood_extent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import numpy as np

from pipelines.flood.determine_alerts import TimeIntervalSeverity
from pipelines.flood.determine_alerts import TimeIntervalReturnPeriodSeverity
from pipelines.infra.data_types.flood_extent_provider import FloodExtentProvider
from pipelines.infra.data_types.loaded_data_types import RasterData


def compute_flood_extent(
time_interval_severities: list[TimeIntervalSeverity],
time_interval_severities: list[TimeIntervalReturnPeriodSeverity],
flood_extent_provider: FloodExtentProvider,
) -> RasterData:
"""
Expand All @@ -26,7 +26,7 @@ def compute_flood_extent(


def _resolve_requested_return_period_value(
time_interval_severities: list[TimeIntervalSeverity],
time_interval_severities: list[TimeIntervalReturnPeriodSeverity],
) -> float | None:
highest_return_period = max(
time_interval_severities,
Expand Down
Loading
Loading