Spaces:
Running on Zero
Running on Zero
| """ | |
| 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() |