Spaces:
Running on Zero
Running on Zero
File size: 10,730 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 | """
Download BD TOPO hydrography (troncon_hydrographique, surface_hydrographique)
for the Eure/Risle area via IGN's Geoplateforme WFS, instead of pulling the
whole national dataset.
VERIFICATION NOTE: I confirmed the WFS endpoint itself is real and live
(https://data.geopf.fr/wfs, WFS 2.0.0) and that a "BDTOPO_V3" namespace
exists on it, by fetching GetCapabilities directly. I could NOT confirm
the exact typeName for the hydrography layers within it — the full
capabilities document is huge (thousands of layers across all IGN
products) and got truncated before reaching them. DEFAULT_TYPENAMES
below is my best guess based on IGN's usual naming convention, NOT a
verified value. This script's first step (check_typenames) tests each
name with a tiny query before attempting the real extraction, and tells
you exactly how to find the right name yourself if my guess is wrong.
Usage:
python download_bdtopo_hydro.py --check # verify typeNames first
python download_bdtopo_hydro.py # then run the real extraction
"""
import argparse
import sys
from pathlib import Path
import requests
import pandas as pd
WFS_URL = "https://data.geopf.fr/wfs"
# Best-guess typeNames -- see VERIFICATION NOTE above. Override with
# --troncon-typename / --surface-typename / --catchment-typename if wrong.
# troncon/surface confirmed correct against the real service (see
# CONFIRMED note below); catchment is still an unverified guess.
DEFAULT_TRONCON_TYPENAME = "BDTOPO_V3:troncon_hydrographique"
DEFAULT_SURFACE_TYPENAME = "BDTOPO_V3:surface_hydrographique"
DEFAULT_CATCHMENT_TYPENAME = "BDTOPO_V3:bassin_versant_topographique"
# CONFIRMED: both troncon_hydrographique and surface_hydrographique
# typeNames work against the live service (verified in this project's
# actual run: 30045 tronçons fetched successfully). bassin_versant_
# topographique has NOT been verified the same way -- run --check
# before trusting it.
# Bounding box around the Eure/Risle stations, in WGS84 (matches the
# lon/lat range in your real station_list.csv, with a small margin).
BBOX = (0.3, 48.3, 1.7, 49.5) # (min_lon, min_lat, max_lon, max_lat)
def find_hydro_typenames(keyword: str = "hydrographique") -> list:
"""
Fetch GetCapabilities and search for FeatureType <Name> entries
containing `keyword`. This is the fallback if DEFAULT_*_TYPENAME
turns out to be wrong -- run this to get the real list.
"""
print(f"Fetching WFS GetCapabilities and searching for '{keyword}'...")
resp = requests.get(WFS_URL, params={
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetCapabilities",
}, timeout=60)
resp.raise_for_status()
import re
names = re.findall(r"<Name>([^<]*)</Name>", resp.text)
matches = [n for n in names if keyword.lower() in n.lower()]
print(f"Found {len(matches)} matching layer(s):")
for m in matches:
print(f" {m}")
return matches
def check_typename(type_name: str) -> bool:
"""Tiny test query (count=1, no bbox) to confirm a typeName is valid
before running the real extraction against it."""
params = {
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetFeature",
"TYPENAMES": type_name, "COUNT": 1, "OUTPUTFORMAT": "application/json",
}
resp = requests.get(WFS_URL, params=params, timeout=30)
ok, error_detail = _parse_wfs_response(resp)
status = "OK" if ok else f"FAILED"
print(f" {type_name}: {status}")
if not ok:
print(f" {error_detail}")
return ok
def _parse_wfs_response(resp: "requests.Response") -> "tuple[bool, str]":
"""
Check whether a WFS response is a real FeatureCollection or an error
(WFS ExceptionReport, or a non-JSON/HTML error page). Returns
(is_valid, detail) -- detail is empty on success, otherwise a
human-readable explanation of what actually came back, so a failure
is never silently swallowed into "0 features".
"""
if resp.status_code != 200:
return False, f"HTTP {resp.status_code}: {resp.text[:400]}"
try:
data = resp.json()
except ValueError:
# Not JSON at all -- almost always means an XML ExceptionReport
# or an HTML error page came back instead.
return False, f"Response was not JSON (likely a WFS ExceptionReport): {resp.text[:400]}"
if isinstance(data, dict) and data.get("type") == "FeatureCollection":
return True, ""
# Some WFS error responses come back as JSON but without a
# FeatureCollection shape (e.g. {"exceptionText": [...]})
return False, f"Response was JSON but not a FeatureCollection: {str(data)[:400]}"
def fetch_features(type_name: str, bbox: tuple, out_path: Path, srs: str = "EPSG:4326") -> None:
"""
GetFeature request over the given bbox, paginated via startIndex
since a national layer clipped to a small bbox can still exceed the
server's per-request feature limit.
AXIS ORDER: when BBOX specifies its CRS via the URN form
(urn:ogc:def:crs:EPSG::4326), the OGC spec requires lat,lon axis
order -- NOT the traditional lon,lat most tools use. Getting this
backwards doesn't error, it just silently matches zero real-world
features (a well-known WFS 2.0 gotcha). Rather than guess, this
tries lon,lat first and automatically retries with lat,lon swapped
if the first attempt comes back empty, so a silent axis-order bug
can't hide as "no data in this area" again.
"""
min_lon, min_lat, max_lon, max_lat = bbox
orderings = [
("lon,lat", f"{min_lon},{min_lat},{max_lon},{max_lat},urn:ogc:def:crs:EPSG::4326"),
("lat,lon", f"{min_lat},{min_lon},{max_lat},{max_lon},urn:ogc:def:crs:EPSG::4326"),
]
all_features = []
for label, bbox_param in orderings:
print(f" trying axis order {label}...")
all_features = _fetch_paginated(type_name, bbox_param)
if all_features:
print(f" -> {label} worked ({len(all_features)} feature(s)). "
f"Use this axis order for future queries against this endpoint.")
break
print(f" -> {label} returned 0 features.")
import json
geojson = {"type": "FeatureCollection", "features": all_features}
out_path.write_text(json.dumps(geojson))
if all_features:
print(f"Saved {len(all_features)} feature(s) to {out_path}")
else:
print(f"Saved an EMPTY file to {out_path} -- both axis orders returned "
f"nothing real. The typeName is valid (confirmed by --check), so this "
f"suggests the bbox itself doesn't overlap any features in this layer, "
f"or another parameter is off. Try a much larger bbox (e.g. all of "
f"France: -5,41,10,51) as a sanity check.")
def _fetch_paginated(type_name: str, bbox_param: str) -> list:
"""One bbox ordering's worth of paginated GetFeature calls."""
all_features = []
start_index = 0
page_size = 1000
while True:
params = {
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetFeature",
"TYPENAMES": type_name, "BBOX": bbox_param,
"OUTPUTFORMAT": "application/json",
"COUNT": page_size, "STARTINDEX": start_index,
}
resp = requests.get(WFS_URL, params=params, timeout=60)
is_valid, error_detail = _parse_wfs_response(resp)
if not is_valid:
print(f" ERROR at startIndex={start_index}: {error_detail}")
break
data = resp.json()
features = data.get("features", [])
if not features:
break
all_features.extend(features)
if len(features) < page_size:
break
start_index += page_size
return all_features
def main() -> None:
parser = argparse.ArgumentParser(description="Download BD TOPO hydrography for Eure/Risle")
parser.add_argument("--check", action="store_true",
help="Only verify typeNames, don't download anything")
parser.add_argument("--troncon-typename", default=DEFAULT_TRONCON_TYPENAME)
parser.add_argument("--surface-typename", default=DEFAULT_SURFACE_TYPENAME)
parser.add_argument("--catchment-typename", default=DEFAULT_CATCHMENT_TYPENAME)
parser.add_argument("--skip-catchments", action="store_true",
help="Skip bassin_versant_topographique (untested typeName)")
parser.add_argument("--output-dir", type=Path, default=Path("datasets/bdtopo_hydro"))
args = parser.parse_args()
if args.check:
print("=" * 70)
print("Testing default typeNames...")
print("=" * 70)
ok_troncon = check_typename(args.troncon_typename)
ok_surface = check_typename(args.surface_typename)
ok_catchment = args.skip_catchments or check_typename(args.catchment_typename)
if not (ok_troncon and ok_surface and ok_catchment):
print()
print("At least one default typeName failed. Searching capabilities "
"for the real name(s) instead:")
find_hydro_typenames("hydrographique")
find_hydro_typenames("bassin_versant")
print()
print("Re-run with --troncon-typename / --surface-typename / "
"--catchment-typename set to whatever the search above found, "
"then --check again to confirm.")
else:
print()
print("All typeNames OK. Re-run without --check to download the real data.")
return
args.output_dir.mkdir(parents=True, exist_ok=True)
print("Fetching troncon_hydrographique (river centerline geometry + attributes)...")
fetch_features(args.troncon_typename, BBOX, args.output_dir / "troncon_hydrographique.geojson")
print()
print("Fetching surface_hydrographique (includes karst/Nature classification)...")
fetch_features(args.surface_typename, BBOX, args.output_dir / "surface_hydrographique.geojson")
if not args.skip_catchments:
print()
print("Fetching bassin_versant_topographique (catchment polygons)...")
print("NOTE: this typeName is unverified -- if it fails, run --check first "
"to find the real name, or pass --skip-catchments to skip it.")
fetch_features(args.catchment_typename, BBOX, args.output_dir / "bassin_versant_topographique.geojson")
print()
print("Done. Load with: BDTopoHydroLoader(data_path=...)")
print("To check for the karst-flagged reach near Grosley-sur-Risle/Ajou, use "
"loader.check_karst_near_point(lat, lon) -- that's the check that would "
"confirm or deny the bétoire location.")
if __name__ == "__main__":
main() |