""" 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()