River_Network / scripts /compute_cumulative_catchment.py
ageraustine's picture
Upload folder using huggingface_hub
d6b0b7a verified
Raw
History Blame Contribute Delete
9.16 kB
"""
Fills catchment_area_km2 for every reach graph node (confluences,
splits, rejoins, virtual infill -- not just the ~8 real gauges Hub'Eau
publishes it for), using BD TOPO's incremental catchment polygons
(bassin_versant_topographique.geojson).
ALGORITHM:
1. Spatially assign each incremental polygon to the edges it contains
(an edge's midpoint falling inside a polygon means that edge's
reach is the one that polygon's incremental drainage feeds).
2. For any node, cumulative catchment area = sum of the AREAS OF
DISTINCT POLYGONS among every edge upstream of it (walked via the
real directed graph, unbounded -- unlike the split/rejoin check's
15km search radius, catchment area needs the true full upstream
drainage, however far that extends). "Distinct" matters: many
edges (and the virtual nodes on them) share the same polygon, since
BD TOPO's confluence-to-confluence stretches are far coarser than
our node resolution -- summing per-edge would massively overcount.
CAVEAT: the polygon assignment step needs geopandas, which isn't
available in the environment this was developed in (no network access
to install it). The graph-traversal/area-summation logic (the actual
novel part) is tested with synthetic polygon assignments standing in
for a real spatial join -- but the spatial join itself has not been run
against real data. Please verify the polygon assignment step
specifically (e.g. spot-check a few edges against the map) before
trusting the final areas.
Usage:
python -m scripts.compute_cumulative_catchment --data-root datasets
"""
import argparse
import sys
from pathlib import Path
import networkx as nx
import pandas as pd
def assign_edges_to_polygons(edges_df: pd.DataFrame, nodes_df: pd.DataFrame, polygons_path: Path) -> pd.Series:
"""
Spatial join: for each edge, which incremental catchment polygon
contains its midpoint. Returns a Series indexed like edges_df, with
the polygon's unique identifier (or NaN if no polygon contains it --
can happen near basin edges or bbox boundaries).
NEEDS geopandas -- not verified in the environment this was written
in, see module docstring.
"""
import geopandas as gpd
from shapely.geometry import Point
polygons = gpd.read_file(polygons_path)
if "cleabs" in polygons.columns:
poly_id_col = "cleabs"
else:
polygons = polygons.reset_index().rename(columns={"index": "_poly_idx"})
poly_id_col = "_poly_idx"
coord = nodes_df.set_index("station_code")[["latitude", "longitude"]]
midpoints = []
for _, e in edges_df.iterrows():
try:
src, tgt = coord.loc[e["source"]], coord.loc[e["target"]]
except KeyError:
midpoints.append(None)
continue
midpoints.append(Point((src["longitude"] + tgt["longitude"]) / 2,
(src["latitude"] + tgt["latitude"]) / 2))
edge_points = gpd.GeoDataFrame({"edge_idx": range(len(edges_df))}, geometry=midpoints, crs=polygons.crs)
joined = gpd.sjoin(edge_points.dropna(subset=["geometry"]), polygons[[poly_id_col, "geometry"]],
how="left", predicate="within")
joined = joined.drop_duplicates("edge_idx") # a midpoint exactly on a shared boundary could match >1 polygon
result = pd.Series(index=range(len(edges_df)), dtype=object)
result.loc[joined["edge_idx"]] = joined[poly_id_col].values
return result
def compute_cumulative_catchment(
nodes_df: pd.DataFrame,
edges_df: pd.DataFrame,
edge_polygon_id: pd.Series,
polygon_areas: dict,
) -> pd.Series:
"""
For every node, sum the areas of distinct polygons among all edges
upstream of it (inclusive of the edge(s) arriving at the node
itself). Pure graph-traversal + arithmetic -- no geospatial
dependency, fully testable without geopandas (see the test in this
script's accompanying verification, or run this module's functions
directly against synthetic data).
Args:
nodes_df: reach graph nodes [station_code, ...]
edges_df: reach graph edges [source, target, ...], same row
order/index as edge_polygon_id.
edge_polygon_id: from assign_edges_to_polygons (or an equivalent
manually-built mapping for testing) -- polygon ID per edge,
aligned to edges_df's index.
polygon_areas: {polygon_id: area_km2}
Returns:
Series indexed like nodes_df, cumulative catchment area in km2
(NaN for a node with no upstream edges assigned to any polygon
at all -- a true headwater with no BD TOPO catchment coverage,
distinct from zero).
"""
G = nx.DiGraph()
G.add_nodes_from(nodes_df["station_code"])
edge_to_poly = {}
for i, e in edges_df.iterrows():
G.add_edge(e["source"], e["target"])
poly = edge_polygon_id.get(i)
if pd.notna(poly):
edge_to_poly[(e["source"], e["target"])] = poly
results = {}
for node in nodes_df["station_code"]:
ancestors = nx.ancestors(G, node) | {node}
# every edge whose TARGET is in this node's ancestor-or-self set
# is an edge that feeds into this node's upstream drainage
polys_here = set()
for (u, v), poly in edge_to_poly.items():
if v in ancestors:
polys_here.add(poly)
if not polys_here:
results[node] = float("nan")
else:
results[node] = sum(polygon_areas.get(p, 0.0) for p in polys_here)
return pd.Series(results).reindex(nodes_df["station_code"])
def run_for_basin(data_root: Path, basin_id: int, file_key: str) -> None:
graph_dir = data_root / "reach_graph"
enriched_path = graph_dir / f"{file_key}_nodes_enriched.csv"
edges_path = graph_dir / f"{file_key}_edges.csv"
polygons_path = data_root / "bdtopo_hydro" / "bassin_versant_topographique.geojson"
if not enriched_path.exists() or not edges_path.exists():
print(f"{file_key}: missing enriched nodes/edges, run build_reach_graphs.py + "
f"enrich_reach_graph.py first. Skipping.")
return
if not polygons_path.exists():
print(f"{file_key}: no {polygons_path} found -- run download_bdtopo_hydro.py first. Skipping.")
return
nodes_df = pd.read_csv(enriched_path)
edges_df = pd.read_csv(edges_path)
print(f"{file_key}: {len(nodes_df)} nodes, {len(edges_df)} edges")
try:
import geopandas as gpd
except ImportError:
print(f"{file_key}: geopandas not installed -- cannot do the spatial join. Skipping.")
return
polygons = gpd.read_file(polygons_path)
poly_id_col = "cleabs" if "cleabs" in polygons.columns else "_poly_idx"
if poly_id_col == "_poly_idx":
polygons = polygons.reset_index().rename(columns={"index": poly_id_col})
polygons_metric = polygons.to_crs(epsg=2154)
polygon_areas = dict(zip(polygons[poly_id_col], polygons_metric.geometry.area / 1e6))
edge_polygon_id = assign_edges_to_polygons(edges_df, nodes_df, polygons_path)
n_assigned = edge_polygon_id.notna().sum()
print(f" {n_assigned}/{len(edges_df)} edges assigned to a polygon "
f"({len(edges_df) - n_assigned} unassigned -- near bbox/basin edges, expected)")
cumulative = compute_cumulative_catchment(nodes_df, edges_df, edge_polygon_id, polygon_areas)
nodes_df["cumulative_catchment_area_km2"] = cumulative.values
n_covered = nodes_df["cumulative_catchment_area_km2"].notna().sum()
print(f" cumulative_catchment_area_km2 coverage: {n_covered}/{len(nodes_df)} nodes "
f"(vs. {nodes_df['catchment_area_km2'].notna().sum()} from Hub'Eau's own reported values)")
# Cross-check against Hub'Eau's real values where both exist, as a
# sanity check on the whole pipeline -- they measure different things
# (Hub'Eau: official gauge catchment; this: BD TOPO polygon sum) so
# exact agreement isn't expected, but they should be in the same
# ballpark for a real gauge, not off by an order of magnitude.
both = nodes_df.dropna(subset=["catchment_area_km2", "cumulative_catchment_area_km2"])
if not both.empty:
both = both.copy()
both["ratio"] = both["cumulative_catchment_area_km2"] / both["catchment_area_km2"]
print(f" Cross-check vs Hub'Eau (real gauges only): ratio mean={both['ratio'].mean():.2f}, "
f"range=[{both['ratio'].min():.2f}, {both['ratio'].max():.2f}]")
print(both[["station_code", "catchment_area_km2", "cumulative_catchment_area_km2", "ratio"]]
.to_string(index=False))
nodes_df.to_csv(enriched_path, index=False)
print(f" updated {enriched_path}")
print()
def main() -> None:
parser = argparse.ArgumentParser(description="Compute cumulative catchment area from BD TOPO polygons")
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
args = parser.parse_args()
for basin_id, file_key in [(0, "eure"), (1, "risle")]:
run_for_basin(args.data_root, basin_id, file_key)
if __name__ == "__main__":
main()