""" Analyze the BD TOPO hydrography pulled by download_bdtopo_hydro.py: 1. check_karst_near_betoire() -- does surface_hydrographique actually have a karst-classified feature near the amont/aval bétoire stations (H605641101 / H605641201)? Pure Python, no geopandas needed, so this part works immediately regardless of what's installed. 2. export_real_centerline() -- replace our approximate digitized centerline (traced from a road-map screenshot, ~4km georeferencing error) with the real troncon_hydrographique geometry for a named river, properly ordered. Needs geopandas + shapely. Usage: python analyze_bdtopo_hydro.py --check-karst python analyze_bdtopo_hydro.py --export-centerline "Risle" --output datasets/centerlines/risle_centerline.csv """ import argparse import json import math from pathlib import Path # Known amont/aval bétoire station coordinates (from station_list.csv) BETOIRE_STATIONS = { "H605641101": ("Ajou [amont bétoire]", 48.98492, 0.78902), "H605641201": ("Grosley-sur-Risle [aval bétoire]", 49.04707, 0.79984), } def _haversine_km(lat1, lon1, lat2, lon2) -> float: R = 6371.0 lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) dlat, dlon = lat2 - lat1, lon2 - lon1 a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 return R * 2 * math.asin(math.sqrt(a)) def _feature_min_distance_km(feature: dict, lat: float, lon: float) -> float: """Minimum distance from (lat, lon) to any vertex of a GeoJSON LineString/MultiLineString/Point feature. Vertex-level, not true point-to-line distance, but plenty precise at this scale given typical vertex spacing.""" geom = feature.get("geometry") or {} gtype = geom.get("type") coords = geom.get("coordinates") if coords is None: return float("inf") def flatten(c, depth): if depth == 0: yield c else: for sub in c: yield from flatten(sub, depth - 1) depth = {"Point": 0, "LineString": 1, "MultiLineString": 2, "Polygon": 2, "MultiPolygon": 3}.get(gtype) if depth is None: return float("inf") best = float("inf") for pt in flatten(coords, depth): plon, plat = pt[0], pt[1] d = _haversine_km(lat, lon, plat, plon) if d < best: best = d return best def check_karst_near_betoire( surface_hydro_path: Path, search_radius_km: float = 3.0, ) -> None: """ Search surface_hydrographique.geojson for any feature whose `Nature` property mentions karst, within `search_radius_km` of either bétoire station. Prints a clear verdict either way -- this is the check that actually answers whether the bétoire naming is backed by a mapped feature or just a station-naming convention. """ data = json.loads(surface_hydro_path.read_text()) features = data.get("features", []) print(f"Loaded {len(features)} feature(s) from {surface_hydro_path}") karst_features = [] for f in features: nature = (f.get("properties") or {}).get("nature") or (f.get("properties") or {}).get("Nature") or "" if "karst" in str(nature).lower(): karst_features.append(f) print(f"Features with 'karst' in Nature: {len(karst_features)}") if not karst_features: print() print("VERDICT: No karst-classified surface_hydrographique feature found " "anywhere in the downloaded area. Either this stretch isn't tagged " "as karst in BD TOPO (the 'bétoire' naming may be informal/historical " "rather than a currently-mapped classification), or the Nature " "property uses different wording than expected -- worth printing a " "few sample Nature values to check (see below).") sample_natures = {(f.get("properties") or {}).get("nature") or (f.get("properties") or {}).get("Nature") for f in features[:200]} print(f"Sample Nature values seen in this file: {sorted(str(n) for n in sample_natures if n)[:20]}") return print() for f in karst_features: props = f.get("properties", {}) for code, (name, lat, lon) in BETOIRE_STATIONS.items(): dist = _feature_min_distance_km(f, lat, lon) flag = " <-- WITHIN SEARCH RADIUS" if dist <= search_radius_km else "" print(f" Karst feature {props.get('nature', props.get('Nature'))!r} " f"is {dist:.2f} km from {code} ({name}){flag}") close = [f for f in karst_features for code, (name, lat, lon) in BETOIRE_STATIONS.items() if _feature_min_distance_km(f, lat, lon) <= search_radius_km] print() if close: print(f"VERDICT: CONFIRMED -- {len(close)} karst-classified feature(s) found " f"within {search_radius_km} km of the bétoire stations. The station " f"naming is backed by an actual mapped karst feature, not just " f"historical naming convention.") else: nearest = min( (_feature_min_distance_km(f, lat, lon), code) for f in karst_features for code, (name, lat, lon) in BETOIRE_STATIONS.items() ) print(f"VERDICT: Karst features exist in this area, but the nearest one to " f"a bétoire station is {nearest[0]:.2f} km away (from {nearest[1]}) -- " f"outside the {search_radius_km} km search radius. Consider widening " f"--search-radius-km if this still seems plausibly related.") def export_real_centerline( troncon_path: Path, river_name: str, output_path: Path, name_field_candidates=("cpx_toponyme_de_cours_d_eau", "toponyme", "nom_cours_d_eau"), ) -> None: """ Filter troncon_hydrographique to the tronçons belonging to a named river, merge and order them into a single sequence, and write out a centerline CSV in the same [seq, longitude, latitude] shape as our digitized centerlines -- so this can be a drop-in replacement with real metric-precision geometry instead of the ~4km-accurate traced version. Requires geopandas + shapely. """ try: import geopandas as gpd import networkx as nx except ImportError as e: raise ImportError("export_real_centerline needs geopandas and networkx: " "pip install geopandas networkx") from e gdf = gpd.read_file(troncon_path) print(f"Loaded {len(gdf)} tronçons from {troncon_path}") print(f"Available columns: {list(gdf.columns)}") name_field = next((c for c in name_field_candidates if c in gdf.columns), None) if name_field is None: raise ValueError( f"None of {name_field_candidates} found in columns. " f"Inspect gdf.columns and pass the right one manually." ) river_gdf = gdf[gdf[name_field].astype(str).str.contains(river_name, case=False, na=False)] print(f"Matched {len(river_gdf)} tronçon(s) for river name '{river_name}' " f"(field: {name_field})") if river_gdf.empty: sample = gdf[name_field].dropna().unique()[:20] print(f"No matches. Sample values in '{name_field}': {list(sample)}") return # Build a graph from tronçon endpoints to find the correct traversal # order (a tronçon's own row order in the file is not guaranteed to # follow the river's course -- same lesson as station_list.csv). G = nx.Graph() for _, row in river_gdf.iterrows(): coords = [(c[0], c[1]) for c in row.geometry.coords] # drop Z (altitude) if present start, end = coords[0], coords[-1] G.add_edge(start, end, coords=coords) if G.number_of_nodes() == 0: print("No valid geometry found.") return components = list(nx.connected_components(G)) if len(components) > 1: print(f"Warning: river tronçons form {len(components)} disconnected " f"component(s) -- using the largest one ({max(len(c) for c in components)} nodes). " f"This can happen at basin edges or with name-matching gaps.") main_component = max(components, key=len) subG = G.subgraph(main_component) # Walk the longest path through the component (same double-BFS # technique used for the digitized centerline extraction). start_node = next(iter(subG.nodes)) lengths = nx.single_source_shortest_path_length(subG, start_node) a = max(lengths, key=lengths.get) lengths2 = nx.single_source_shortest_path_length(subG, a) b = max(lengths2, key=lengths2.get) node_path = nx.shortest_path(subG, a, b) all_coords = [] for u, v in zip(node_path[:-1], node_path[1:]): edge_coords = subG.edges[u, v]["coords"] if edge_coords[0] != u: edge_coords = list(reversed(edge_coords)) all_coords.extend(edge_coords if not all_coords else edge_coords[1:]) import pandas as pd result = pd.DataFrame(all_coords, columns=["longitude", "latitude"]) result.insert(0, "seq", range(len(result))) output_path.parent.mkdir(parents=True, exist_ok=True) result.to_csv(output_path, index=False) print(f"Saved {len(result)}-point real centerline for '{river_name}' to {output_path}") print("This is real metric-precision BD TOPO geometry -- replace the old " "digitized version by pointing centerline_dir at this file.") def main() -> None: parser = argparse.ArgumentParser(description="Analyze BD TOPO hydrography") parser.add_argument("--data-dir", type=Path, default=Path("datasets/bdtopo_hydro")) parser.add_argument("--check-karst", action="store_true") parser.add_argument("--search-radius-km", type=float, default=3.0) parser.add_argument("--export-centerline", type=str, default=None, help="River name to extract, e.g. 'Risle' or 'Eure'") parser.add_argument("--output", type=Path, default=None) args = parser.parse_args() if args.check_karst: check_karst_near_betoire(args.data_dir / "surface_hydrographique.geojson", args.search_radius_km) if args.export_centerline: out = args.output or Path(f"datasets/centerlines/{args.export_centerline.lower()}_centerline_real.csv") export_real_centerline(args.data_dir / "troncon_hydrographique.geojson", args.export_centerline, out) if __name__ == "__main__": main()