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