File size: 7,849 Bytes
f2046b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Real, independent karst cross-check for every real gauge station, using
the actual raw BDCavités data -- not the precomputed distance_to_
nearest_cavity_km column, which turned out to never have been saved
anywhere in the current reach-graph pipeline (confirmed directly: the
column is absent from both real enriched node files, despite the raw
cavité data itself existing on disk).

Built specifically because the current karst/losing-reach flag
(verified_continuous=False on 3 real edges of La Risle's bétoire
stretch) is a hardcoded list based on real station naming evidence
("[amont bétoire]"/"[aval bétoire]"), not a systematic scan -- it can
only ever confirm what was already suspected from names, never surface
a DIFFERENT real station that also sits near a real cavity but never
got a "bétoire"-style name. This script is that systematic scan.

Usage:
    python -m scripts.analysis.check_cavity_proximity --data-root datasets
"""
import argparse
import json
from pathlib import Path
from typing import List, Optional

import numpy as np
import pandas as pd


def _haversine_km_vec(lat1, lon1, lat2, lon2):
    """Real haversine distance, vectorized -- same formula used
    throughout this project's other real distance computations."""
    R = 6371.0
    lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
    dlat, dlon = lat2 - lat1, lon2 - lon1
    a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
    return R * 2 * np.arcsin(np.sqrt(a))


def load_real_cavity_coordinates(geojson_path: Path, allowed_types: Optional[List[str]] = None) -> np.ndarray:
    """
    Extracts real [lat, lon] pairs directly from the raw BDCavités
    GeoJSON -- point features only, skipping anything malformed rather
    than crashing the whole load on a single bad real record (large
    real government datasets do have occasional genuine data-quality
    issues; skip-and-count is more useful here than a hard failure).

    allowed_types, when given, filters on the real "type_cavite" field.
    Confirmed as a real, necessary filter, not an optional refinement:
    an unfiltered first run showed 24 of 27 real stations within 1km of
    *some* cavity, with counts in the thousands -- because BDCavités
    catalogs every cavity type, and the real breakdown (confirmed
    directly against this project's actual file) is dominated by
    quarries (carrière, 21,813), civil/military structures (ouvrage
    civil/militaire, ~9,900), and cellars (cave, 3,990) -- none
    hydrologically meaningful. Only "naturelle" (8,313) corresponds to
    genuine natural cavities, the category that could actually indicate
    real karst/water-loss connectivity. "indéterminé" (18,349) is
    genuinely ambiguous -- could hide real unclassified natural
    features or unclassified quarries -- so it's deliberately not
    included by default; check separately with it included rather than
    assume either way.
    """
    with open(geojson_path) as f:
        data = json.load(f)

    coords = []
    n_skipped = 0
    n_filtered = 0
    for feature in data.get("features", []):
        geom = feature.get("geometry")
        if geom is None or geom.get("type") != "Point":
            n_skipped += 1
            continue
        if allowed_types is not None:
            real_type = feature.get("properties", {}).get("type_cavite")
            if real_type not in allowed_types:
                n_filtered += 1
                continue
        lon, lat = geom["coordinates"][0], geom["coordinates"][1]
        coords.append((lat, lon))

    filter_msg = f", {n_filtered} filtered out by type" if allowed_types is not None else ""
    print(f"Loaded {len(coords)} real cavity point(s) from {geojson_path.name} "
          f"({n_skipped} non-point/malformed feature(s) skipped{filter_msg})")
    return np.array(coords)


def check_cavity_proximity(stations_df: pd.DataFrame, cavity_coords: np.ndarray, radius_km: float = 20.0) -> pd.DataFrame:
    """
    Real nearest-cavity distance and real cavity count within radius_km,
    per real station -- same two metrics documented for the (never
    actually saved) distance_to_nearest_cavity_km / n_cavities_within_
    20km features, computed fresh here directly against the real raw
    data.
    """
    cav_lat, cav_lon = cavity_coords[:, 0], cavity_coords[:, 1]
    results = []
    for _, row in stations_df.iterrows():
        dists = _haversine_km_vec(row["latitude"], row["longitude"], cav_lat, cav_lon)
        nearest_km = float(dists.min())
        n_within_radius = int((dists <= radius_km).sum())
        results.append({
            "station_code": row["station_code"],
            "distance_to_nearest_cavity_km": nearest_km,
            "n_cavities_within_20km": n_within_radius,
        })
    return pd.DataFrame(results).sort_values("distance_to_nearest_cavity_km")


def main() -> None:
    parser = argparse.ArgumentParser(description="Real cavity-proximity cross-check for every real gauge station")
    parser.add_argument("--data-root", type=Path, default=Path("datasets"))
    parser.add_argument("--stations-path", type=Path, default=None)
    parser.add_argument("--close-threshold-km", type=float, default=1.0,
                         help="Real distance below which a station is flagged as plausibly karst-connected.")
    args = parser.parse_args()

    stations_path = args.stations_path or (args.data_root / "station_elevations.csv")
    stations_df = pd.read_csv(stations_path)

    geojson_path = args.data_root / "bdcavites" / "cavite_localisee.geojson"
    if not geojson_path.exists():
        print(f"No real cavity file found at {geojson_path} -- run "
              f"scripts.data_acquisition.download_bdcavites first.")
        return

    # Two real, confirmed-necessary filtered runs, not one -- an
    # unfiltered check showed 24/27 real stations within 1km of *some*
    # cavity (counts in the thousands), because BDCavités catalogs
    # every cavity type and quarries/civil structures/cellars dominate
    # the real breakdown. "naturelle" is the conservative, defensible
    # filter; "naturelle + indéterminé" is a broader one that also
    # captures any real natural feature that was never properly typed
    # -- reported side by side rather than picking one and hiding the
    # other's disagreement.
    for label, allowed_types in [
        ("naturelle only (conservative)", ["naturelle"]),
        ("naturelle + indéterminé (broader)", ["naturelle", "indéterminé"]),
    ]:
        print()
        print("=" * 70)
        print(f"Filter: {label}")
        print("=" * 70)
        cavity_coords = load_real_cavity_coordinates(geojson_path, allowed_types=allowed_types)
        if len(cavity_coords) == 0:
            print("Zero real cavities matched this filter -- nothing to check against.")
            continue

        result_df = check_cavity_proximity(stations_df, cavity_coords)
        print(result_df.to_string(index=False))

        close = result_df[result_df["distance_to_nearest_cavity_km"] < args.close_threshold_km]
        print()
        if len(close) > 0:
            print(f"Station(s) within {args.close_threshold_km}km of a real, filtered cavity "
                  f"(cross-check against the known bétoire stations -- H605641101, H605022010, "
                  f"H605641401, H605641201 -- anything else here is a real, new candidate):")
            print(close.to_string(index=False))
        else:
            print(f"No real station within {args.close_threshold_km}km of a real, filtered cavity.")

        output_path = args.data_root / f"cavity_proximity_check_{allowed_types[0]}.csv"
        result_df.to_csv(output_path, index=False)
        print(f"Saved to {output_path}")
    print(f"\nSaved to {output_path}")


if __name__ == "__main__":
    main()