River_Network / scripts /analyze_bdtopo_hydro.py
ageraustine's picture
Upload folder using huggingface_hub
a74054f verified
Raw
History Blame Contribute Delete
10.6 kB
"""
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()