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