Spaces:
Running on Zero
Running on Zero
File size: 9,161 Bytes
d6b0b7a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """
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() |