Spaces:
Running on Zero
Running on Zero
File size: 12,812 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 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 | """
BD TOPO hydrography loader.
Single Responsibility: Load IGN BD TOPO hydrographic data (river
centerline tronçons, hydrographic surfaces, catchment polygons) as
downloaded by scripts/download_bdtopo_hydro.py.
Data source: IGN Geoplateforme WFS (https://data.geopf.fr/wfs), Licence
Ouverte 2.0. See download_bdtopo_hydro.py for the actual download step
and its axis-order/typeName caveats -- this loader assumes that data is
already on disk as GeoJSON.
"""
import json
import math
from pathlib import Path
from typing import Optional, List, Tuple
import pandas as pd
import matplotlib.pyplot as plt
from .base import BaseDataLoader
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))
class BDTopoHydroLoader(BaseDataLoader):
"""
Loads BD TOPO hydrography for a given area: river centerline
tronçons (with real elevation at every vertex), hydrographic
surfaces (includes karst classification), and catchment polygons.
Unlike the other loaders in this project, BD TOPO data doesn't come
as flat CSVs -- it's GeoJSON with 3D (lon, lat, altitude) LineString
geometry. This loader keeps everything as plain dicts/DataFrames
(no geopandas/shapely dependency) wherever the operation allows it,
since several of these (elevation profile, karst proximity) only
need vertex coordinates, not real geometric operations. Catchment
polygon loading DOES need geopandas (point-in-polygon is a real
geometric operation), so that one method degrades gracefully with a
clear message if geopandas isn't installed.
"""
def __init__(self, data_path: Path):
"""
Args:
data_path: directory containing troncon_hydrographique.geojson,
surface_hydrographique.geojson, and (optionally)
bassin_versant_topographique.geojson, as produced by
download_bdtopo_hydro.py.
"""
super().__init__(data_path)
def load(self) -> dict:
"""
Load the raw troncon_hydrographique GeoJSON (the primary
dataset for this loader -- river centerline tronçons).
Returns:
The parsed GeoJSON dict ({"type": "FeatureCollection", "features": [...]})
"""
path = self.data_path / "troncon_hydrographique.geojson"
if not path.exists():
raise FileNotFoundError(f"No troncon_hydrographique.geojson found in {self.data_path}")
return json.loads(path.read_text())
def load_surfaces(self) -> dict:
"""Load surface_hydrographique.geojson (includes karst classification)."""
path = self.data_path / "surface_hydrographique.geojson"
if not path.exists():
raise FileNotFoundError(f"No surface_hydrographique.geojson found in {self.data_path}")
return json.loads(path.read_text())
def load_catchments(self):
"""
Load bassin_versant_topographique.geojson as a GeoDataFrame.
Requires geopandas (real polygon geometry, unlike the other
methods here). Raises a clear ImportError if unavailable rather
than a confusing geopandas stack trace.
"""
try:
import geopandas as gpd
except ImportError as e:
raise ImportError(
"load_catchments needs geopandas: pip install geopandas"
) from e
path = self.data_path / "bassin_versant_topographique.geojson"
if not path.exists():
raise FileNotFoundError(f"No bassin_versant_topographique.geojson found in {self.data_path}")
return gpd.read_file(path)
def get_metadata(self) -> dict:
"""Get BD TOPO hydrography metadata."""
meta = super().get_metadata()
meta.update({
"data_type": "bdtopo_hydrography",
"source_organization": "IGN (Geoplateforme WFS, data.geopf.fr)",
"license": "Licence Ouverte 2.0",
})
return meta
# ------------------------------------------------------------------
# River name filtering + topology (shared by several methods below)
# ------------------------------------------------------------------
def _tronçons_for_river(
self, geojson: dict, river_name: str,
name_fields=("cpx_toponyme_de_cours_d_eau", "toponyme", "nom_cours_d_eau"),
) -> List[dict]:
"""Filter troncon_hydrographique features to those matching a river name."""
features = geojson.get("features", [])
if not features:
return []
name_field = next((f for f in name_fields if f in features[0].get("properties", {})), None)
if name_field is None:
raise ValueError(f"None of {name_fields} found in feature properties.")
river_name_lower = river_name.lower()
return [
f for f in features
if river_name_lower in str(f.get("properties", {}).get(name_field, "")).lower()
]
# ------------------------------------------------------------------
# Elevation profile from embedded Z-coordinates
# ------------------------------------------------------------------
def get_elevation_profile(self, river_name: str, geojson: Optional[dict] = None) -> pd.DataFrame:
"""
Real elevation profile along a named river, using the Z
(altitude) coordinate already embedded in every tronçon vertex
-- far finer resolution than interpolating between the 15-27
gauge stations we otherwise have elevation for.
Does NOT solve the "132 disconnected components" ordering
problem (see analyze_bdtopo_hydro.py's export_real_centerline
for the graph-based ordering approach) -- this returns raw
(lon, lat, elevation_m) points, unordered across tronçon
boundaries, sorted only by elevation descending. Fine for
elevation range / distribution questions; use
export_real_centerline's ordered output if you need a proper
upstream-to-downstream sequence.
Args:
river_name: e.g. "Risle" or "Eure".
geojson: optional pre-loaded troncon_hydrographique dict.
Returns:
DataFrame [longitude, latitude, elevation_m], sorted by
elevation_m descending (upstream-ish to downstream-ish).
"""
geojson = geojson or self.load()
matched = self._tronçons_for_river(geojson, river_name)
if not matched:
raise ValueError(f"No tronçons matched river name '{river_name}'")
rows = []
for f in matched:
coords = f.get("geometry", {}).get("coordinates", [])
for c in coords:
if len(c) >= 3:
rows.append({"longitude": c[0], "latitude": c[1], "elevation_m": c[2]})
if not rows:
raise ValueError(
f"Matched {len(matched)} tronçon(s) for '{river_name}' but none had a Z "
f"(elevation) coordinate -- this BD TOPO export may be 2D-only."
)
df = pd.DataFrame(rows).drop_duplicates()
return df.sort_values("elevation_m", ascending=False).reset_index(drop=True)
# ------------------------------------------------------------------
# Karst proximity check
# ------------------------------------------------------------------
def check_karst_near_point(
self,
latitude: float,
longitude: float,
search_radius_km: float = 3.0,
surfaces_geojson: Optional[dict] = None,
) -> List[dict]:
"""
Find karst-classified surface_hydrographique features within
`search_radius_km` of a point. General-purpose version of the
amont/aval-bétoire check built for this project's Risle
investigation -- works for any point.
Args:
latitude, longitude: the point to search around.
search_radius_km: only features with a vertex within this
distance are returned.
surfaces_geojson: optional pre-loaded surface_hydrographique dict.
Returns:
List of {"nature": str, "distance_km": float} for every
matching karst feature, nearest first. Empty list if none found.
"""
geojson = surfaces_geojson or self.load_surfaces()
features = geojson.get("features", [])
results = []
for f in features:
props = f.get("properties", {})
nature = props.get("nature") or props.get("Nature") or ""
if "karst" not in str(nature).lower():
continue
dist = self._feature_min_distance_km(f, latitude, longitude)
if dist <= search_radius_km:
results.append({"nature": nature, "distance_km": dist})
return sorted(results, key=lambda r: r["distance_km"])
@staticmethod
def _feature_min_distance_km(feature: dict, lat: float, lon: float) -> float:
"""Minimum distance from (lat, lon) to any vertex of a GeoJSON feature."""
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):
d = _haversine_km(lat, lon, pt[1], pt[0])
if d < best:
best = d
return best
# ------------------------------------------------------------------
# Plots
# ------------------------------------------------------------------
def plot_elevation_profile(
self,
river_name: str,
df: Optional[pd.DataFrame] = None,
figsize: tuple = (10, 5),
save_path: Optional[Path] = None,
) -> plt.Axes:
"""
Scatter of real BD TOPO vertex elevations along a named river,
against distance-from-mouth approximated by cumulative point
index after sorting -- NOT a true ordered profile (see
get_elevation_profile's docstring), but useful for seeing the
actual elevation range/density from real data at a glance.
Args:
river_name: e.g. "Risle" or "Eure".
df: optional pre-computed get_elevation_profile() output.
figsize: figure size in inches.
save_path: if provided, saves the figure to this path.
Returns:
The matplotlib Axes object.
"""
if df is None:
df = self.get_elevation_profile(river_name)
fig, ax = plt.subplots(figsize=figsize)
ax.scatter(range(len(df)), df["elevation_m"], s=3, alpha=0.4, color="#2E6F95")
ax.set_title(f"{river_name} — real BD TOPO vertex elevations ({len(df)} points)",
fontsize=12, fontweight="bold")
ax.set_xlabel("Point index (sorted by elevation, not true along-river order)")
ax.set_ylabel("Elevation (m)")
ax.grid(True, alpha=0.3)
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Plot saved to {save_path}")
return ax
def plot_catchments(
self,
gdf=None,
figsize: tuple = (9, 8),
save_path: Optional[Path] = None,
) -> plt.Axes:
"""
Plot catchment (bassin versant topographique) polygons. Requires
geopandas.
Args:
gdf: optional pre-loaded load_catchments() output.
figsize: figure size in inches.
save_path: if provided, saves the figure to this path.
Returns:
The matplotlib Axes object.
"""
if gdf is None:
gdf = self.load_catchments()
fig, ax = plt.subplots(figsize=figsize)
gdf.plot(ax=ax, color="#A9C6D8", edgecolor="#2E6F95", alpha=0.6, linewidth=0.8)
ax.set_title(f"Catchment Polygons (n={len(gdf)})", fontsize=12, fontweight="bold")
ax.set_xlabel("Longitude")
ax.set_ylabel("Latitude")
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=150, bbox_inches="tight")
print(f"Plot saved to {save_path}")
return ax |