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()