Spaces:
Running on Zero
Running on Zero
File size: 10,561 Bytes
a74054f | 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 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | """
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() |