Spaces:
Running on Zero
Running on Zero
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() |