-
Notifications
You must be signed in to change notification settings - Fork 0
p-phung/flood hazard logic #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
92 commits
Select commit
Hold shift + click to select a range
b154f64
first outline
p-phung 2e19510
add details hazard-logic process steps
p-phung fa1eb73
logic for overlaying population and flood extent
p-phung 96f2a56
extract station from list of stations
p-phung 43d9ab1
use population = 0 if no flood extent
p-phung 1507d83
manually load input data
p-phung ba778fb
reformat flood extent resolver
p-phung 56de525
config for test runs
p-phung 42eb1bc
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung 86fc08b
rename terminologies from triggered to alert, fix flood extent resolv…
p-phung a060321
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung c59cad7
move utils out of infra
p-phung d4b493c
rename file
p-phung 3388c00
clipped flood extent for alert extent
p-phung 3eee565
replace lead_time with time_interval
p-phung 888c03d
implement changes related to last commits
p-phung 2ea3e6c
rename vars from triggered to alert
p-phung 6a306af
rename vars from triggered to alert
p-phung c8631c8
rename file, rm slice nc file inside loop
p-phung 2b29eea
rename file, simplify flood extent resolver
p-phung bfa3ee5
rename function for clarity
p-phung a6c5986
split functions for clarity
p-phung 6c9ff89
switch to loop through stations for more clarity
p-phung 097aa7f
update tests
p-phung 62032ae
add readme
p-phung 357ae91
update readme
p-phung 1fb2581
update readme
p-phung d13adbf
fix minor Copilot comments
p-phung 49351a2
replace assert with explicit FileNotFound
p-phung 5a63d1c
switch order for alert integrity checks: if there is exposure then cr…
p-phung 773380e
load nc with context manager
p-phung afa5fed
merge remote updates
p-phung dc3d3ac
rename LeadTime elements to TimeInterval elements
p-phung 173a61d
explicit lead time min max values
p-phung 82e3356
rename vars for more descriptive
p-phung 9c13b2e
reorder main func to top
p-phung 0a4c7c3
rename function from lead_time to temporal_extent
p-phung 441033f
rename variables for more explicit
p-phung c1085f7
Apply suggestions from code review (round 1)
p-phung 9463d50
rename arg from lead_time to time_interval
p-phung f895e1e
mark todo, todo-infra, comments
p-phung 8c548fb
rename left over lead_time
p-phung a61368c
mv test_resolve_flood_extent to test/unit/flood
p-phung 250a8a8
change lead_time to time_interval in test_resovle_flood_extent
p-phung d3c9f3a
restructure to follow defined flowchart better
p-phung e635006
update readme
p-phung bea7dea
catch when flood extent path not exist
p-phung 08d70f1
separate config for ETH test
p-phung 977cdad
fix flood raster path in data submitter
p-phung 1abf0ea
rm unused import
p-phung 93aaf81
rename arg time_intervals to time_interval_discharges for clarity
p-phung e660a87
rename variable for consistency
p-phung 9b3f114
create some todos
p-phung 656a250
specify explicitly no-data variable
p-phung fb748a3
rm unused function
p-phung b8a0728
create todo
p-phung 9090741
rename variables for consistency
p-phung 6d98e4d
rm unused function
p-phung efa6e86
create new function for repetative step
p-phung dcd00aa
keep NaN in discharge data and handle it when taking median
p-phung 315a630
rm unnecessary check
p-phung 0e36419
restore config to default
p-phung 1bb41b7
replace station code to station name in event name
p-phung 3af0920
split functions for step-wise clarity
p-phung ec94ffb
separate functions for clarity
p-phung 55bff01
Merge branch upstream commit
p-phung 93bf643
complete left-over fixes for separating functions, update tests accor…
p-phung 8d954aa
update branch with latest main
p-phung 8257c67
rm limit station loop
p-phung 1dcbcca
update tests for exposed population calculation
p-phung 8309963
improve loop to search for matched return period
p-phung 7d63413
rm unused imports
p-phung 0743777
fix prettier
p-phung 5aa0b78
Merge branch 'main' into p-phung/flood-hazard-logic
gulfaraz ad9ebd7
rm multiplier 100 of mock trigger scenario
p-phung 2c81ab8
test: update test based on infra changes
gulfaraz c2f506d
correct todo task
p-phung 47d2169
update tests to the new target_admin source
p-phung 1da8d64
mv flood related unit tests to test/unit/flood
p-phung 41dbc40
Merge branch 'p-phung/flood-hazard-logic' of https://github.com/rodek…
p-phung 59d55f8
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung b49948f
add debug for alert placecodes
p-phung 5a684fa
add todo for admin-area filtering
p-phung ad5f562
add bronze to gitignore
p-phung 0b4dc61
apply Jannis suggestions from code review
p-phung 27cb61e
Merge branch 'p-phung/flood-hazard-logic' of https://github.com/rodek…
p-phung 6c31cb5
complete recent jannis suggestions
p-phung 8ce1c6f
chore: add input data to gitignore
jannisvisser 45c8321
Merge branch 'p-phung/flood-hazard-logic' of https://github.com/rodek…
p-phung 454783f
make use pop raster's default nodata value
p-phung 4d13107
rm duplicate gitignore lines
p-phung a89b161
Merge branch 'main' into p-phung/flood-hazard-logic
p-phung File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,3 +55,6 @@ share/python-wheels/ | |
| .installed.cfg | ||
| *.egg | ||
| MANIFEST | ||
|
|
||
| # temp pipeline source data | ||
| pipelines/flood/bronze | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/...`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). | ||
| """ | ||
|
|
||
| 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 | ||
|
p-phung marked this conversation as resolved.
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.