Spaces:
Running on Zero
Running on Zero
| """ | |
| Build the reach-based river network graph for both basins from real BD TOPO | |
| data, and report on every step so problems are visible immediately instead | |
| of surfacing later as a silent wrong answer downstream. | |
| Usage: | |
| python -m scripts.build_reach_graphs --data-root datasets | |
| """ | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| import pandas as pd | |
| try: | |
| from src.graph.build_reach_graph import ( | |
| check_flow_direction_coverage, load_troncons_for_basin, build_node_link_digraph, | |
| best_component_for_stations, snap_gauges_to_reach_graph, insert_virtual_nodes, | |
| build_reach_graph_tables, summarize_confluences_by_tributary, TRIBUTARY_NAMES, normalize_toponym, | |
| ) | |
| from src.data.loaders.station_elevations import StationElevationsLoader | |
| from src.data.river_graph import assign_basin_id | |
| except ImportError: | |
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
| from src.graph.build_reach_graph import ( | |
| check_flow_direction_coverage, load_troncons_for_basin, build_node_link_digraph, | |
| best_component_for_stations, snap_gauges_to_reach_graph, insert_virtual_nodes, | |
| build_reach_graph_tables, summarize_confluences_by_tributary, TRIBUTARY_NAMES, normalize_toponym, | |
| ) | |
| from src.data.loaders.station_elevations import StationElevationsLoader | |
| from src.data.river_graph import assign_basin_id | |
| BASIN_NAMES = {0: "La Eure", 1: "La Risle"} | |
| def run_for_basin( | |
| troncon_geojson: dict, | |
| stations_df: pd.DataFrame, | |
| basin_id: int, | |
| virtual_node_spacing_km: float, | |
| anchor_radius_km: float, | |
| component_search_radius_km: float, | |
| ) -> "tuple[pd.DataFrame, pd.DataFrame] | None": | |
| name = BASIN_NAMES[basin_id] | |
| print("=" * 70) | |
| print(f"{name}") | |
| print("=" * 70) | |
| basin_stations = stations_df[stations_df["basin_id"] == basin_id].copy() | |
| matched = load_troncons_for_basin( | |
| troncon_geojson, basin_id=basin_id, | |
| anchor_stations=basin_stations, max_distance_from_anchor_km=anchor_radius_km, | |
| ) | |
| print(f"Matched {len(matched)} tronçons by name AND within {anchor_radius_km} km of a " | |
| f"real gauge (name-only matching pulled in geographically unrelated tronçons " | |
| f"in earlier runs -- see build_reach_graph.py's load_troncons_for_basin docstring)") | |
| if not matched: | |
| print(" Nothing matched -- try a larger --anchor-radius-km, or check " | |
| "TRIBUTARY_NAMES against this file's actual toponym values.") | |
| return None | |
| print() | |
| print("Flow direction field coverage (sens_de_l_ecoulement), all matched tronçons:") | |
| coverage = check_flow_direction_coverage(matched) | |
| for value, count in sorted(coverage.items(), key=lambda x: -x[1]): | |
| print(f" {value!r}: {count}") | |
| print(" If this doesn't show recognizable direction values, " | |
| "build_node_link_digraph's sens_downstream_values/sens_upstream_values " | |
| "need updating before its elevation fallback is the only thing working.") | |
| print() | |
| G, report = build_node_link_digraph(matched) | |
| print(report) | |
| n_known_tributaries = len(TRIBUTARY_NAMES.get(basin_id, [])) - 1 # exclude the main river itself | |
| print(f" ({n_known_tributaries} known named tributaries for this basin -- confluence count " | |
| f"should be in this neighborhood, not far beyond it, if a tributary joins the main " | |
| f"river roughly once)") | |
| tributary_summary = summarize_confluences_by_tributary(G) | |
| if not tributary_summary.empty: | |
| counts = tributary_summary["joining_river"].value_counts() | |
| print(f" Confluences by joining river (post name-normalization): {dict(counts)}") | |
| if len(counts) > n_known_tributaries + 3: # some slack for real sub-tributary branching | |
| print(f" NOTE: more distinct joining-river names ({len(counts)}) than known " | |
| f"tributaries ({n_known_tributaries}) -- worth checking whether some of " | |
| f"these are actually the same river under yet another name variant " | |
| f"normalize_toponym doesn't catch, or genuinely unlisted tributaries.") | |
| if report.disconnected_components > 1: | |
| print(f" {report.disconnected_components} component(s) found. Selecting by which " | |
| f"one actually contains our real gauges, not by raw size -- the two aren't " | |
| f"guaranteed to agree, and in earlier runs they didn't.") | |
| G, gauge_counts = best_component_for_stations(G, basin_stations, search_radius_km=component_search_radius_km) | |
| if len(gauge_counts) > 1: | |
| sorted_counts = sorted(gauge_counts.values(), reverse=True) | |
| print(f" Gauges captured per component: {sorted_counts[:5]}{'...' if len(sorted_counts) > 5 else ''}") | |
| if sorted_counts[0] < len(basin_stations): | |
| print(f" WARNING: the winning component only captures {sorted_counts[0]} of " | |
| f"{len(basin_stations)} gauges -- this basin's real network may still be " | |
| f"fragmented across more than one component even after the anchor filter.") | |
| print() | |
| print(f"Snapping {len(basin_stations)} real gauge(s) onto the reach graph...") | |
| snapped = snap_gauges_to_reach_graph(G, basin_stations) | |
| print(snapped[["station_code", "reach_u", "reach_v", "reach_fraction", "snap_distance_km"]] | |
| .sort_values("snap_distance_km", ascending=False).to_string(index=False)) | |
| unsnapped = snapped["reach_u"].isna().sum() | |
| if unsnapped: | |
| print(f" WARNING: {unsnapped} station(s) got no nearest edge at all -- " | |
| f"they're likely outside this basin's matched tronçon set.") | |
| large_snap = (snapped["snap_distance_km"] > 2.0).sum() | |
| if large_snap: | |
| print(f" NOTE: {large_snap} station(s) snapped more than 2 km from any " | |
| f"reach -- worth checking those aren't actually on an unmatched tributary.") | |
| print() | |
| G_virtual, n_inserted = insert_virtual_nodes(G, spacing_km=virtual_node_spacing_km) | |
| print(f"Inserted {n_inserted} virtual node(s) at ~{virtual_node_spacing_km} km spacing " | |
| f"({G.number_of_nodes()} real nodes -> {G_virtual.number_of_nodes()} total)") | |
| print() | |
| nodes_df, edges_df = build_reach_graph_tables(G_virtual, snapped, basin_id=basin_id) | |
| n_confluences = int(nodes_df["is_confluence"].sum()) | |
| print(f"Final table: {len(nodes_df)} nodes ({nodes_df['is_gauged'].sum()} gauged, " | |
| f"{n_confluences} confluences, {len(nodes_df) - nodes_df['is_gauged'].sum() - n_confluences} " | |
| f"virtual/other), {len(edges_df)} edges") | |
| return nodes_df, edges_df | |
| def main() -> None: | |
| parser = argparse.ArgumentParser(description="Build reach-based river network graphs") | |
| parser.add_argument("--data-root", type=Path, default=Path("datasets")) | |
| parser.add_argument("--virtual-node-spacing-km", type=float, default=5.0) | |
| parser.add_argument("--anchor-radius-km", type=float, default=20.0, | |
| help="Max distance from a real gauge for a name-matched tronçon to be kept") | |
| parser.add_argument("--component-search-radius-km", type=float, default=5.0, | |
| help="How close a gauge must be to a component to count as 'in' it") | |
| parser.add_argument("--output-dir", type=Path, default=None, | |
| help="Defaults to <data-root>/reach_graph") | |
| args = parser.parse_args() | |
| troncon_path = args.data_root / "bdtopo_hydro" / "troncon_hydrographique.geojson" | |
| if not troncon_path.exists(): | |
| print(f"Missing {troncon_path} -- run scripts/download_bdtopo_hydro.py first.") | |
| sys.exit(1) | |
| troncon_geojson = json.loads(troncon_path.read_text()) | |
| stations_df = StationElevationsLoader(data_path=args.data_root / "station_elevations.csv").load() | |
| stations_df["basin_id"] = stations_df["station_code"].apply(assign_basin_id) | |
| unmatched = stations_df["basin_id"].isna().sum() | |
| if unmatched: | |
| print(f"NOTE: {unmatched} station(s) matched no basin prefix and are excluded entirely.\n") | |
| stations_df = stations_df.dropna(subset=["basin_id"]) | |
| stations_df["basin_id"] = stations_df["basin_id"].astype(int) | |
| output_dir = args.output_dir or (args.data_root / "reach_graph") | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| all_nodes, all_edges = [], [] | |
| for basin_id, file_key in [(0, "eure"), (1, "risle")]: | |
| result = run_for_basin(troncon_geojson, stations_df, basin_id, args.virtual_node_spacing_km, | |
| args.anchor_radius_km, args.component_search_radius_km) | |
| print() | |
| if result is None: | |
| continue | |
| nodes_df, edges_df = result | |
| nodes_df.to_csv(output_dir / f"{file_key}_nodes.csv", index=False) | |
| edges_df.to_csv(output_dir / f"{file_key}_edges.csv", index=False) | |
| print(f"Saved {file_key}_nodes.csv / {file_key}_edges.csv to {output_dir}") | |
| print() | |
| all_nodes.append(nodes_df) | |
| all_edges.append(edges_df) | |
| if all_nodes: | |
| print("=" * 70) | |
| print("SUMMARY") | |
| print("=" * 70) | |
| combined_nodes = pd.concat(all_nodes, ignore_index=True) | |
| combined_edges = pd.concat(all_edges, ignore_index=True) | |
| print(f"Total: {len(combined_nodes)} nodes, {len(combined_edges)} edges across both basins") | |
| print(f"Gauged: {combined_nodes['is_gauged'].sum()}") | |
| if __name__ == "__main__": | |
| main() |