Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
b154f64
first outline
p-phung Apr 3, 2026
2e19510
add details hazard-logic process steps
p-phung Apr 3, 2026
fa1eb73
logic for overlaying population and flood extent
p-phung Apr 3, 2026
96f2a56
extract station from list of stations
p-phung Apr 15, 2026
43d9ab1
use population = 0 if no flood extent
p-phung Apr 15, 2026
1507d83
manually load input data
p-phung Apr 15, 2026
ba778fb
reformat flood extent resolver
p-phung Apr 15, 2026
56de525
config for test runs
p-phung Apr 15, 2026
42eb1bc
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung Apr 15, 2026
86fc08b
rename terminologies from triggered to alert, fix flood extent resolv…
p-phung Apr 15, 2026
a060321
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung Apr 22, 2026
c59cad7
move utils out of infra
p-phung Apr 22, 2026
d4b493c
rename file
p-phung Apr 22, 2026
3388c00
clipped flood extent for alert extent
p-phung Apr 22, 2026
3eee565
replace lead_time with time_interval
p-phung Apr 22, 2026
888c03d
implement changes related to last commits
p-phung Apr 22, 2026
2ea3e6c
rename vars from triggered to alert
p-phung Apr 22, 2026
6a306af
rename vars from triggered to alert
p-phung Apr 22, 2026
c8631c8
rename file, rm slice nc file inside loop
p-phung Apr 22, 2026
2b29eea
rename file, simplify flood extent resolver
p-phung Apr 22, 2026
bfa3ee5
rename function for clarity
p-phung Apr 22, 2026
a6c5986
split functions for clarity
p-phung Apr 22, 2026
6c9ff89
switch to loop through stations for more clarity
p-phung Apr 22, 2026
097aa7f
update tests
p-phung Apr 22, 2026
62032ae
add readme
p-phung Apr 24, 2026
357ae91
update readme
p-phung Apr 24, 2026
1fb2581
update readme
p-phung Apr 24, 2026
d13adbf
fix minor Copilot comments
p-phung Apr 24, 2026
49351a2
replace assert with explicit FileNotFound
p-phung Apr 24, 2026
5a63d1c
switch order for alert integrity checks: if there is exposure then cr…
p-phung Apr 24, 2026
773380e
load nc with context manager
p-phung Apr 24, 2026
afa5fed
merge remote updates
p-phung Apr 24, 2026
dc3d3ac
rename LeadTime elements to TimeInterval elements
p-phung Apr 28, 2026
173a61d
explicit lead time min max values
p-phung Apr 28, 2026
82e3356
rename vars for more descriptive
p-phung Apr 28, 2026
9c13b2e
reorder main func to top
p-phung Apr 28, 2026
0a4c7c3
rename function from lead_time to temporal_extent
p-phung Apr 28, 2026
441033f
rename variables for more explicit
p-phung Apr 28, 2026
c1085f7
Apply suggestions from code review (round 1)
p-phung Apr 28, 2026
9463d50
rename arg from lead_time to time_interval
p-phung May 1, 2026
f895e1e
mark todo, todo-infra, comments
p-phung May 1, 2026
8c548fb
rename left over lead_time
p-phung May 1, 2026
a61368c
mv test_resolve_flood_extent to test/unit/flood
p-phung May 1, 2026
250a8a8
change lead_time to time_interval in test_resovle_flood_extent
p-phung May 1, 2026
d3c9f3a
restructure to follow defined flowchart better
p-phung May 1, 2026
e635006
update readme
p-phung May 1, 2026
bea7dea
catch when flood extent path not exist
p-phung May 1, 2026
08d70f1
separate config for ETH test
p-phung May 1, 2026
977cdad
fix flood raster path in data submitter
p-phung May 1, 2026
1abf0ea
rm unused import
p-phung May 11, 2026
93aaf81
rename arg time_intervals to time_interval_discharges for clarity
p-phung May 11, 2026
e660a87
rename variable for consistency
p-phung May 11, 2026
9b3f114
create some todos
p-phung May 11, 2026
656a250
specify explicitly no-data variable
p-phung May 11, 2026
fb748a3
rm unused function
p-phung May 11, 2026
b8a0728
create todo
p-phung May 11, 2026
9090741
rename variables for consistency
p-phung May 11, 2026
6d98e4d
rm unused function
p-phung May 11, 2026
efa6e86
create new function for repetative step
p-phung May 11, 2026
dcd00aa
keep NaN in discharge data and handle it when taking median
p-phung May 11, 2026
315a630
rm unnecessary check
p-phung May 12, 2026
0e36419
restore config to default
p-phung May 12, 2026
1bb41b7
replace station code to station name in event name
p-phung May 12, 2026
3af0920
split functions for step-wise clarity
p-phung May 12, 2026
ec94ffb
separate functions for clarity
p-phung May 12, 2026
55bff01
Merge branch upstream commit
p-phung May 12, 2026
93bf643
complete left-over fixes for separating functions, update tests accor…
p-phung May 12, 2026
8d954aa
update branch with latest main
p-phung May 12, 2026
8257c67
rm limit station loop
p-phung May 13, 2026
1dcbcca
update tests for exposed population calculation
p-phung May 13, 2026
8309963
improve loop to search for matched return period
p-phung May 13, 2026
7d63413
rm unused imports
p-phung May 13, 2026
0743777
fix prettier
p-phung May 13, 2026
5aa0b78
Merge branch 'main' into p-phung/flood-hazard-logic
gulfaraz May 13, 2026
ad9ebd7
rm multiplier 100 of mock trigger scenario
p-phung May 13, 2026
2c81ab8
test: update test based on infra changes
gulfaraz May 13, 2026
c2f506d
correct todo task
p-phung May 13, 2026
47d2169
update tests to the new target_admin source
p-phung May 13, 2026
1da8d64
mv flood related unit tests to test/unit/flood
p-phung May 13, 2026
41dbc40
Merge branch 'p-phung/flood-hazard-logic' of https://github.com/rodek…
p-phung May 13, 2026
59d55f8
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung May 13, 2026
b49948f
add debug for alert placecodes
p-phung May 18, 2026
5a684fa
add todo for admin-area filtering
p-phung May 18, 2026
ad5f562
add bronze to gitignore
p-phung May 18, 2026
0b4dc61
apply Jannis suggestions from code review
p-phung May 18, 2026
27cb61e
Merge branch 'p-phung/flood-hazard-logic' of https://github.com/rodek…
p-phung May 18, 2026
6c31cb5
complete recent jannis suggestions
p-phung May 18, 2026
8ce1c6f
chore: add input data to gitignore
jannisvisser May 18, 2026
45c8321
Merge branch 'p-phung/flood-hazard-logic' of https://github.com/rodek…
p-phung May 18, 2026
454783f
make use pop raster's default nodata value
p-phung May 18, 2026
4d13107
rm duplicate gitignore lines
p-phung May 18, 2026
a89b161
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung May 18, 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
3 changes: 3 additions & 0 deletions data/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ share/python-wheels/
.installed.cfg
*.egg
MANIFEST

# temp pipeline source data
pipelines/flood/bronze
91 changes: 91 additions & 0 deletions data/pipelines/flood/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Flood Pipeline Logic (WIP)

This folder contains the flood-specific forecast logic used by the pipeline framework.

## Main script

- `flood/forecast.py`
- Entry point for flood hazard logic via `calculate_flood_forecasts(...)`:
- Loads station points and admin areas through `DataProvider`, then combines them with local bronze flood inputs.
- Builds alerts, severity time series, admin-area exposure, and raster exposure through `DataSubmitter`.

## Accompanying scripts in this folder

- `extract_forecast.py`
- Samples GloFAS discharge values from (sliced) NetCDF rasters at station coordinates.
- Produces per-station, per-lead-time ensemble discharge series.

- `compute_alert_extent.py`
- Resolves the flood extent raster to use for an alert from the available flood extent files.
- Selects the highest matched return period, falls back to the closest lower return period, then to `*_empty.tif`.

- `determine_exposure.py`
- Reads the station-to-admin-area mapping and filters to place codes present in the loaded admin areas.
- Clips the selected flood extent raster to affected admin areas for raster exposure output.
- Computes an exposed-population raster and aggregates exposed population per place code.

- `utils_raster.py`
- Utility functions for geospatial preprocessing:
- derive country bounding box from admin geometries,
- slice NetCDF to country bounds,
- clip rasters to bounding boxes,
- get raster extent for output metadata.

## Bronze input data

`bronze/` holds preprocessed flood inputs used by `forecast.py`:

- `bronze/glofas/`
- GloFAS discharge NetCDF files (ensemble forecast source).
- Current code points to a local file such as `dis_00_YYYYMMDDHH.nc` and creates `_sliced.nc` files per country run.

- `bronze/thresholds/`
- Country-specific threshold JSON files (`*_{ISO3}.json`).
- Used to convert station discharge values into return-period-based alert severities.

- `bronze/station-district/`
- Station-to-admin mapping JSON (`{ISO3}_station_district_mapping.json`).
- Links station IDs to impacted place codes before flood extent clipping and exposure aggregation.

- `bronze/population/`
- Country population raster (`{ISO3}.tif`).
- Used to create `{ISO3}_exposed.tif`, which is then aggregated over affected admin areas.

- `bronze/flood_extents/`
- Flood extent rasters by return period (`flood_map_{ISO3}_rp*.tif`) plus empty fallback.
- Used to select, clip, and attach alert extent rasters to events.

## `forecast.py` flow (read -> output)

1. Load core inputs:
- Load GloFAS station metadata and target admin areas through `DataProvider`.
- Load threshold JSON, station-district mapping JSON, population raster path, and flood extent raster paths from local `bronze/` files.
- Stop early and record an error if stations or admin areas are missing.

2. Build country spatial extent
- Compute country bounding box from target admin areas.
- Slice NetCDF files once to this bounding box.

3. Process discharge per station
- Iterate through stations and currently limit processing to the first two station entries.
- Extract discharge ensemble values per lead time.
- Derive lead-time severities from thresholds.
- Skip stations with no threshold exceedance.

4. Build alert payload
- Select the alert extent raster based on the matched return periods.
- Clip the flood extent to mapped admin areas and collect exposed place codes.
- Compute the exposed-population raster and aggregate exposed population per place code.

5. Compute exposure
- Create one alert event per alerting station.
- Add severity time-series data for ensemble runs and the median discharge.
- Add admin-area population exposure per place code.
- Add raster exposure metadata for the generated `alert_extent_{station_code}.tif`.

6. Write final output to local forecast folder
- `forecast.py` fills `DataSubmitter`.
- `pipelines/infra/run_forecasts.py` finalizes and writes `forecast.json`.
- Default local base path is `pipelines/output`, resulting in paths like:
- `pipelines/output/floods/{ISO3}/{timestamp}/forecast.json`
- In this repository this appears under `data/pipelines/output/floods/...`.
152 changes: 152 additions & 0 deletions data/pipelines/flood/compute_alert_extent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from __future__ import annotations

import logging
import os

# Supported return period labels used by flood alerts.

RETURN_PERIODS: dict[str, int] = {
"5yr": 5,
"10yr": 10,
"20yr": 20,
"25yr": 25,
"50yr": 50,
"100yr": 100
}


def compute_alert_extent(
time_interval_severities: list,
flood_extent_paths: list[str],
) -> str:
"""
Compute the flood extent raster for the alert station by resolving the appropriate return period raster.
Returns the path to the computed flood extent raster for the station.
"""

return_period = _resolve_requested_return_period_value(time_interval_severities)

flood_extent_path = _resolve_flood_extent(
return_period=return_period,
flood_extent_paths=flood_extent_paths,
)
return flood_extent_path


def _extract_return_period_value(path: str) -> int | None:
"""
Extract the return period value from a flood extent raster file name.
Expected file name format: flood_map_{country}_rp{value}.tif, e.g. flood_map_ETH_rp20.tif -> 20
Returns the return period value as an integer, or None if it cannot be extracted e.g. flood_map_ETH_empty.tif.
"""
filename = os.path.basename(path).lower()

filename_stem, _ = os.path.splitext(filename)
rp_index = filename_stem.rfind("_rp")
if rp_index == -1:
return None

value_text = filename_stem[rp_index + len("_rp") :]
if value_text.isdigit():
return int(value_text)

return None

def _extract_return_period_label_value(return_period_label: str) -> int | None:
"""
Extract the return period value from a return period label.
Expected label format: "Xyr", e.g. "5yr", "20yr".
Returns the return period value as an integer, or None if it cannot be extracted.
"""
normalized_label = return_period_label.strip().lower()

if normalized_label.endswith("yr"):
value_text = normalized_label[:-2].strip()
if value_text.isdigit():
return int(value_text)

return None


def _resolve_empty_flood_extent_path(flood_extent_paths: list[str]) -> str | None:
for flood_extent_path in flood_extent_paths:
filename = os.path.basename(flood_extent_path).lower()

if filename.endswith("_empty.tif"):
return flood_extent_path

filename_stem, _ = os.path.splitext(filename)
rp_index = filename_stem.rfind("_rp")
if rp_index == -1:
continue

base_without_suffix = filename_stem[:rp_index]
directory = os.path.dirname(flood_extent_path)
empty_tif_path = os.path.join(directory, f"{base_without_suffix}_empty.tif")
if os.path.exists(empty_tif_path):
return empty_tif_path

return None

def _resolve_requested_return_period_value(time_interval_severities: list) -> int | None:
"""
Resolve flood extent raster for the highest matched return period
"""
highest_return_period = max(
time_interval_severities,
key=lambda s: s.median_discharge,
).return_period

return_period = RETURN_PERIODS.get(highest_return_period)
if return_period is not None:
return return_period

parsed_return_period = _extract_return_period_label_value(highest_return_period)
if parsed_return_period is None:
logging.warning(
f"Unknown return period '{highest_return_period}', using empty fallback"
)

return parsed_return_period


def _resolve_flood_extent(
return_period: int | None,
flood_extent_paths: list[str],
) -> str:
"""
Resolve the flood extent raster using this order:
1. Exact return period raster.
2. Closest lower-or-equal available return period raster.
3. flood_map_{country}_empty.tif fallback raster (guaranteed to exist).
Comment thread
gulfaraz marked this conversation as resolved.
"""

available_paths_by_value: dict[int, str] = {}
for flood_extent_path in flood_extent_paths:
if not os.path.exists(flood_extent_path):
continue
value = _extract_return_period_value(flood_extent_path)
if value is not None:
available_paths_by_value[value] = flood_extent_path

if return_period is not None and return_period in available_paths_by_value:
return available_paths_by_value[return_period]

if return_period is not None:
fallback_value = max(
(value for value in available_paths_by_value.keys() if value <= return_period),
default=None,
)
if fallback_value is not None:
return available_paths_by_value[fallback_value]

# Return empty flood extent
empty_path = _resolve_empty_flood_extent_path(flood_extent_paths)
if empty_path is None:
raise FileNotFoundError(
"Could not resolve flood extent raster: no suitable return period raster "
"or no empty fallback raster was found among existing files."
)

return empty_path
Comment thread
p-phung marked this conversation as resolved.

145 changes: 145 additions & 0 deletions data/pipelines/flood/determine_alerts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import TypedDict

import numpy as np

from pipelines.flood.extract_forecast import TimeIntervalDischarge
from pipelines.infra.data_types.location_point import LocationPoint

MINIMUM_RETURN_PERIOD = "1.5yr"


@dataclass
class TimeIntervalSeverity:
time_interval_start: str
time_interval_end: str
median_discharge: float
ensemble_discharges: list[float]
return_period: str


@dataclass
class AlertStation:
station_code: str
station: LocationPoint
time_interval_severities: list[TimeIntervalSeverity]


class ReturnPeriodThresholdValue(TypedDict):
return_period: float
threshold_value: float


class ReturnPeriodThresholds(TypedDict):
station_code: str
thresholds: list[ReturnPeriodThresholdValue]


def determine_temporal_extent(
station_code: str,
time_interval_discharges: list[TimeIntervalDischarge],
thresholds: list[ReturnPeriodThresholds],
minimum_return_period: str = MINIMUM_RETURN_PERIOD,
) -> list[TimeIntervalSeverity]:
"""
Compute lead time severities for one station by comparing the median
ensemble discharge against return period thresholds.
Returns a list of lead time severities for the station.
"""
station_thresholds = _prepare_station_threshold(
station_code, thresholds, minimum_return_period
)
if station_thresholds is None:
return []

time_interval_severities: list[TimeIntervalSeverity] = []
for time_interval_discharge in time_interval_discharges:
ensemble_array = np.asarray(
time_interval_discharge.ensemble_discharges,
dtype=float,
)
if np.isnan(ensemble_array).all():
continue
median_discharge = float(np.nanmedian(ensemble_array))
return_period = _match_return_period(median_discharge, station_thresholds)
if return_period is not None:
time_interval_severities.append(
TimeIntervalSeverity(
time_interval_start=time_interval_discharge.time_interval_start,
time_interval_end=time_interval_discharge.time_interval_end,
median_discharge=median_discharge,
ensemble_discharges=time_interval_discharge.ensemble_discharges,
return_period=return_period,
)
)

return time_interval_severities


def _format_return_period_label(return_period: float) -> str:
return f"{return_period:g}yr"


def _get_station_return_period_thresholds(
thresholds: list[ReturnPeriodThresholds],
station_code: str,
) -> dict[str, float] | None:
for station_threshold in thresholds:
if station_threshold["station_code"] != station_code:
continue

return {
_format_return_period_label(threshold["return_period"]): threshold[
"threshold_value"
]
for threshold in station_threshold["thresholds"]
}

return None


def _match_return_period(
discharge: float,
station_thresholds: dict[str, float],
) -> str | None:
"""
Find the highest return period whose threshold the discharge exceeds.
Thresholds are expected as e.g. {"2yr": 100, "5yr": 200, "10yr": 350, ...}.
Returns the label of the highest exceeded return period, or None if none exceeded.
"""
# sort thresholds descending by value to find the highest exceeded efficiently
for return_period, threshold_value in sorted(
station_thresholds.items(), key=lambda item: item[1], reverse=True
):
if discharge > threshold_value:
return return_period
return None


def _prepare_station_threshold(
station_code: str,
thresholds: list[ReturnPeriodThresholds],
minimum_return_period: str = MINIMUM_RETURN_PERIOD,
) -> dict[str, float] | None:
"""
Retrieve and validate station return-period thresholds.
Returns the station thresholds dict, or None if validation fails.
"""
station_thresholds = _get_station_return_period_thresholds(thresholds, station_code)
if station_thresholds is None:
logging.warning(
f"No return period thresholds for station {station_code}, skipping"
)
return None

if minimum_return_period not in station_thresholds:
logging.warning(
f"Return period '{minimum_return_period}' not found for station {station_code}, skipping"
)
return None

return station_thresholds

Loading
Loading