Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions 1b_pip_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@

ipykernel
pytest

# Optional `floodpath[interactive]` extras — installed by default in dev
# envs so contributors can run the interactive-module tests and the
# notebook-based outlet picker. End-users who only want the core pipeline
# can `pip install floodpath` and skip these.
leafmap
ipyleaflet
matplotlib
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,70 @@ print(f"Outlet peak Q: {discharge.outlet_peak():.1f} m³/s")
print(f"Total rainfall-driven damage: {damage.values.sum():,.0f} m² built-up")
```

## Quickstart — interactive outlet selection (ArcSWAT-style)

For an ArcSWAT-style workflow — fetch a DEM patch, render flow accumulation
and the stream network on a slippy map, then click the pixel you want to
use as the watershed outlet — install the optional `interactive` extras
and use `floodpath.interactive.pick_outlet` from a Jupyter notebook:

```bash
pip install floodpath[interactive] # adds leafmap, ipyleaflet, matplotlib
```

```python
from floodpath.interactive import pick_outlet

picker = pick_outlet(lat=11.805, lon=37.5625, buffer_deg=0.0375)
picker.show() # renders the leafmap widget — click a pixel on a stream

# In a follow-up cell, after clicking:
selection = picker.selection
print(f"Outlet snapped to: {selection.outlet}")
print(f"Upstream basin: {selection.basin.cell_count} cells")
```

The picker auto-snaps each click to the nearest downstream stream cell
(via D8 trace) and overlays the delineated upstream basin. The returned
`OutletSelection` bundles the snapped outlet, the basin mask, and the
DEM / flow grid / streams used to compute it — feed those straight into
the rest of the pipeline:

```python
from floodpath.hydrology import compute_hand
from floodpath.damage import compute_inundation_depth, compute_damage, JRC_AFRICA_RESIDENTIAL

hand = compute_hand(
grid=selection.flow_grid,
streams=selection.streams,
dem=selection.dem,
)
depth = compute_inundation_depth(hand, water_level=5.0)
# ...
```

For headless / scripted use (no map widget), call `picker.select(lat, lon)`
directly — it returns the same `OutletSelection`.

For an end-to-end demo that wires the picker into the full pipeline
(DEM → flow → streams → outlet → HAND → flood → population affected →
damage), see [`examples/pick_outlet.ipynb`](examples/pick_outlet.ipynb)
on GitHub.

## Modules

| Module | Source | What it provides |
|---|---|---|
| `floodpath.dem` | Copernicus GLO-30 (AWS Open Data, COG) | Elevation patch around any (lat, lon) |
| `floodpath.hydrology` | derived from DEM via `pyflwdir` | Flow direction + accumulation, stream networks (with Strahler order), basin delineation, HAND |
| `floodpath.hydrology` | derived from DEM via `pyflwdir` | Flow direction + accumulation, stream networks (with Strahler order), basin delineation, snap-to-stream, HAND |
| `floodpath.exposure` | GHSL R2023A, WorldPop, OpenStreetMap (Overpass) | Built-up surface, population, building footprints |
| `floodpath.landuse` | ESA WorldCover (10 m, AWS Open Data, COG) | 11-class land-cover raster (2020 v100, 2021 v200), Manning's roughness derivation |
| `floodpath.soil` | ISRIC SoilGrids 2.0 (250 m, COG) | Sand/silt/clay topsoil composition + USDA texture-triangle classification + NEH 630 Ch7 hydrologic soil group (A/B/C/D) |
| `floodpath.precip` | Synthetic uniform (real fetchers later: ERA5 / IMERG / CHIRPS) | Precipitation depth raster (mm) — pluggable input to the runoff equation |
| `floodpath.runoff` | NEH 630 Ch9 + Ch10 + landuse + HSG + precip | SCS Curve Number raster + SCS-CN runoff equation `Q = (P-0.2S)²/(P+0.8S)` |
| `floodpath.routing` | runoff + flow direction (pyflwdir) + roughness + HAND | Hydrologic routing (accumulation + peak discharge) + hydraulic closure (Manning normal-depth at streams, Leopold-Maddock width) + rainfall-driven HAND flood depth |
| `floodpath.damage` | JRC Huizinga 2017 + DEM/HAND/GHSL/routing | Per-cell flood depth and damage in m² of built-up surface — accepts either a static water-level scenario or a rainfall-driven flood from `floodpath.routing` |
| `floodpath.interactive` | leafmap + ipyleaflet + matplotlib (optional extras) | Jupyter-based ArcSWAT-style outlet picker: hillshade + streams + click-to-snap + basin delineation |

## Depth-damage curves

Expand Down
699 changes: 699 additions & 0 deletions examples/pick_outlet.ipynb

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions floodpath/hydrology/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .flow import build_flow_grid
from .hand import compute_hand
from .models import Basin, FlowGrid, Hand, StreamNetwork
from .snap import snap_to_stream
from .streams import extract_streams

__all__ = [
Expand All @@ -15,4 +16,5 @@
"compute_hand",
"delineate_basin",
"extract_streams",
"snap_to_stream",
]
60 changes: 60 additions & 0 deletions floodpath/hydrology/snap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Snap an arbitrary point to the nearest downstream stream cell."""

import numpy as np
from rasterio.transform import xy as rio_xy

from .models import FlowGrid, StreamNetwork


def snap_to_stream(
grid: FlowGrid,
streams: StreamNetwork,
point: tuple[float, float],
) -> tuple[tuple[float, float], tuple[int, int]]:
"""Trace flow downstream from a point until it hits the stream network.

User clicks on a hillshaded DEM almost never land exactly on a stream
cell. To turn a free-form click into a hydrologically meaningful
outlet, follow the D8 flow direction from the click cell until the
first stream cell is reached. Implemented via `pyflwdir`'s `snap`,
which supports stopping on a boolean mask along the flow path.

Args:
grid: FlowGrid produced by `build_flow_grid`.
streams: StreamNetwork derived from `grid` via `extract_streams`.
point: `(lat, lon)` of the user-supplied click, decimal degrees.

Returns:
Tuple `((lat, lon), (row, col))` for the snapped stream cell.

Raises:
ValueError: The click is outside the DEM bbox, OR drains off the
patch edge without crossing a stream cell. Both surface as
ValueError so callers can handle them with one branch.
"""
lat, lon = point
try:
idxs, _ = grid.flwdir_raster.snap(
xy=(np.array([lon]), np.array([lat])),
mask=streams.mask,
direction="down",
)
except IndexError as e:
raise ValueError(
f"Click at (lat={lat}, lon={lon}) is outside the DEM patch. "
f"Pick a point inside the patch, or rebuild the picker with a "
f"larger buffer."
) from e
_, n_cols = streams.shape
flat_idx = int(idxs[0])
row, col = divmod(flat_idx, n_cols)

if not bool(streams.mask[row, col]):
raise ValueError(
f"Click at (lat={lat}, lon={lon}) drains off the patch edge "
f"without reaching a stream cell. Try a larger DEM buffer, "
f"or lower the stream threshold (current: {streams.threshold})."
)

lon_snapped, lat_snapped = rio_xy(grid.transform, row, col)
return (lat_snapped, lon_snapped), (row, col)
14 changes: 14 additions & 0 deletions floodpath/interactive/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Interactive module: ArcSWAT-style outlet selection on a leafmap widget.

Optional dependency. Install with `pip install floodpath[interactive]` to
pull in `leafmap`, `ipyleaflet`, and `matplotlib`.
"""

from .models import OutletSelection
from .picker import OutletPicker, pick_outlet

__all__ = [
"OutletPicker",
"OutletSelection",
"pick_outlet",
]
44 changes: 44 additions & 0 deletions floodpath/interactive/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Constants for the interactive module."""

# Initial leafmap zoom for the picker. 12 ≈ city scale, comfortable for the
# default DEM_BUFFER_DEG=0.1 (~22 km wide) patch.
DEFAULT_MAP_ZOOM: int = 12

# Default leafmap basemap. CartoDB.Positron is a near-monochrome light tile
# layer (pale gray + thin labels) — it pairs cleanly with the grayscale
# hillshade so the colored stream / basin / outlet overlays dominate.
# Override per-call via `pick_outlet(..., basemap=...)`. Other low-noise
# options worth trying: "Esri.WorldImagery" (satellite, ArcSWAT-style),
# "Esri.WorldGrayCanvas" (very neutral gray), "OpenTopoMap" (topo lines).
DEFAULT_BASEMAP: str = "CartoDB.Positron"

# Default leafmap widget size. None means leafmap's native default — the
# widget fills the notebook cell width. Override per-call by passing an
# explicit `width=` / `height=` (CSS strings like "700px" or "60%") when
# you want a compact inspector instead of a full-cell map.
DEFAULT_MAP_WIDTH: str | None = None
DEFAULT_MAP_HEIGHT: str | None = None

# Hillshade parameters — azimuth from N (315° = NW), altitude in degrees.
# Matches ArcMap's default hillshade for visual familiarity.
HILLSHADE_AZIMUTH_DEG: float = 315.0
HILLSHADE_ALTITUDE_DEG: float = 45.0

# Stream styling: width is scaled by Strahler order so trunk channels stand
# out from headwaters, mirroring the ArcSWAT visual convention. Widths are
# kept generous on purpose — clicks auto-snap downstream so the user does
# not need pixel-precise aim, but the network has to read clearly from a
# normal zoom level to be useful as a visual guide.
STREAM_COLOR: str = "#1f78b4"
STREAM_BASE_WIDTH: float = 3.0
STREAM_WIDTH_PER_ORDER: float = 1.2

# Basin polygon style. Fill is kept light so streams underneath remain
# visible after a basin is drawn.
BASIN_FILL_COLOR: str = "#33a02c"
BASIN_FILL_OPACITY: float = 0.15
BASIN_BORDER_COLOR: str = "#1b6f1b"
BASIN_BORDER_WIDTH: float = 2.0

# Outlet marker color (snapped pixel center).
OUTLET_MARKER_COLOR: str = "#e31a1c"
26 changes: 26 additions & 0 deletions floodpath/interactive/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Dataclasses for the interactive module."""

from dataclasses import dataclass

from floodpath.dem.models import DEM
from floodpath.hydrology.models import Basin, FlowGrid, StreamNetwork


@dataclass
class OutletSelection:
"""Result of an interactive outlet selection.

Bundles the outlet pixel chosen by the user (snapped to the stream
network) with the basin it drains and the terrain data computed for
the picker session. Downstream pipeline stages (`compute_hand`,
routing, damage) can be invoked directly from these fields without
re-fetching or recomputing.
"""

outlet: tuple[float, float]
raw_click: tuple[float, float]
pour_pixel: tuple[int, int]
basin: Basin
dem: DEM
flow_grid: FlowGrid
streams: StreamNetwork
Loading
Loading