Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- scripts/__pycache__/compute_edge_width.cpython-311.pyc +0 -0
- scripts/__pycache__/diagnose_persistence_baseline.cpython-311.pyc +0 -0
- scripts/__pycache__/download_c3s_seasonal.cpython-311.pyc +0 -0
- scripts/__pycache__/fetch_ecmwf_forecast.cpython-311.pyc +0 -0
- scripts/__pycache__/fetch_eure_risle_stations.cpython-311.pyc +0 -0
- scripts/__pycache__/fetch_previous_runs.cpython-311.pyc +0 -0
- scripts/__pycache__/identify_flood_events.cpython-311.pyc +0 -0
- scripts/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc +0 -0
- scripts/__pycache__/validate_forecast_skill.cpython-311.pyc +0 -0
- scripts/analysis/__pycache__/check_cavity_proximity.cpython-311.pyc +0 -0
- scripts/analysis/__pycache__/check_forecast_lead5_anomaly.cpython-311.pyc +0 -0
- scripts/analysis/__pycache__/trace_forecast_lookup.cpython-311.pyc +0 -0
- scripts/analysis/analyze_bdtopo_hydro.py +245 -0
- scripts/analysis/check_cavity_proximity.py +169 -0
- scripts/analysis/check_forecast_lead5_anomaly.py +120 -0
- scripts/analysis/cross_check_catchments.py +50 -0
- scripts/analysis/run_bdtopo_checks.py +71 -0
- scripts/analysis/trace_forecast_lookup.py +127 -0
- scripts/data_acquisition/__pycache__/fetch_meteofrance_precipitation.cpython-311.pyc +0 -0
- scripts/data_acquisition/download_bdcavites.py +146 -0
- scripts/data_acquisition/download_bdcharm.py +107 -0
- scripts/data_acquisition/download_bdtopo_hydro.py +244 -0
- scripts/data_acquisition/download_c3s_seasonal.py +181 -0
- scripts/data_acquisition/download_catchment.py +114 -0
- scripts/data_acquisition/download_elevation.py +67 -0
- scripts/data_acquisition/download_era5_full.py +180 -0
- scripts/data_acquisition/download_era5_sample.py +44 -0
- scripts/data_acquisition/download_hubeau.py +162 -0
- scripts/data_acquisition/extract_era5.py +57 -0
- scripts/data_acquisition/fetch_ecmwf_forecast.py +152 -0
- scripts/data_acquisition/fetch_idpr_brgm.py +144 -0
- scripts/data_acquisition/fetch_landcover.py +134 -0
- scripts/data_acquisition/fetch_meteofrance_precipitation.py +240 -0
- scripts/data_acquisition/fetch_previous_runs.py +175 -0
- scripts/data_acquisition/fetch_worldcover_ndvi.py +155 -0
- scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc +0 -0
- scripts/evaluation/evaluate_flood_detection.py +986 -0
- scripts/evaluation/identify_flood_events.py +151 -0
- scripts/graph_building/__pycache__/enrich_reach_graph.cpython-311.pyc +0 -0
- scripts/graph_building/build_dynamic_tensors.py +117 -0
- scripts/graph_building/build_reach_graphs.py +188 -0
- scripts/graph_building/compute_cumulative_catchment.py +208 -0
- scripts/graph_building/compute_edge_width.py +109 -0
- scripts/graph_building/diagnose_confluences.py +135 -0
- scripts/graph_building/diagnose_persistence_baseline.py +124 -0
- scripts/graph_building/enrich_reach_graph.py +126 -0
- scripts/tests/__pycache__/cross_validate.cpython-311.pyc +0 -0
- scripts/tests/__pycache__/test_climate_climatology.cpython-311.pyc +0 -0
- scripts/tests/__pycache__/test_climate_climatology_grid.cpython-311.pyc +0 -0
.gitattributes
CHANGED
|
@@ -219,3 +219,4 @@ datasets/c3s_seasonal/hindcast_month08.nc filter=lfs diff=lfs merge=lfs -text
|
|
| 219 |
datasets/meteofrance_precipitation/daily_precipitation.csv filter=lfs diff=lfs merge=lfs -text
|
| 220 |
datasets/single_trace_H605641201_2026-01-01.png filter=lfs diff=lfs merge=lfs -text
|
| 221 |
datasets/spatiotemporal_gnn/selected_subgraph.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 219 |
datasets/meteofrance_precipitation/daily_precipitation.csv filter=lfs diff=lfs merge=lfs -text
|
| 220 |
datasets/single_trace_H605641201_2026-01-01.png filter=lfs diff=lfs merge=lfs -text
|
| 221 |
datasets/spatiotemporal_gnn/selected_subgraph.png filter=lfs diff=lfs merge=lfs -text
|
| 222 |
+
scripts/training/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
scripts/__pycache__/compute_edge_width.cpython-311.pyc
ADDED
|
Binary file (7.6 kB). View file
|
|
|
scripts/__pycache__/diagnose_persistence_baseline.cpython-311.pyc
ADDED
|
Binary file (8.35 kB). View file
|
|
|
scripts/__pycache__/download_c3s_seasonal.cpython-311.pyc
ADDED
|
Binary file (10.1 kB). View file
|
|
|
scripts/__pycache__/fetch_ecmwf_forecast.cpython-311.pyc
ADDED
|
Binary file (8.71 kB). View file
|
|
|
scripts/__pycache__/fetch_eure_risle_stations.cpython-311.pyc
ADDED
|
Binary file (9.52 kB). View file
|
|
|
scripts/__pycache__/fetch_previous_runs.cpython-311.pyc
ADDED
|
Binary file (9.88 kB). View file
|
|
|
scripts/__pycache__/identify_flood_events.cpython-311.pyc
ADDED
|
Binary file (9.2 kB). View file
|
|
|
scripts/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc
ADDED
|
Binary file (79.9 kB). View file
|
|
|
scripts/__pycache__/validate_forecast_skill.cpython-311.pyc
ADDED
|
Binary file (9.21 kB). View file
|
|
|
scripts/analysis/__pycache__/check_cavity_proximity.cpython-311.pyc
ADDED
|
Binary file (10.4 kB). View file
|
|
|
scripts/analysis/__pycache__/check_forecast_lead5_anomaly.cpython-311.pyc
ADDED
|
Binary file (8.01 kB). View file
|
|
|
scripts/analysis/__pycache__/trace_forecast_lookup.cpython-311.pyc
ADDED
|
Binary file (7.87 kB). View file
|
|
|
scripts/analysis/analyze_bdtopo_hydro.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analyze the BD TOPO hydrography pulled by download_bdtopo_hydro.py:
|
| 3 |
+
|
| 4 |
+
1. check_karst_near_betoire() -- does surface_hydrographique actually
|
| 5 |
+
have a karst-classified feature near the amont/aval bétoire stations
|
| 6 |
+
(H605641101 / H605641201)? Pure Python, no geopandas needed, so this
|
| 7 |
+
part works immediately regardless of what's installed.
|
| 8 |
+
|
| 9 |
+
2. export_real_centerline() -- replace our approximate digitized
|
| 10 |
+
centerline (traced from a road-map screenshot, ~4km georeferencing
|
| 11 |
+
error) with the real troncon_hydrographique geometry for a named
|
| 12 |
+
river, properly ordered. Needs geopandas + shapely.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python analyze_bdtopo_hydro.py --check-karst
|
| 16 |
+
python analyze_bdtopo_hydro.py --export-centerline "Risle" --output datasets/centerlines/risle_centerline.csv
|
| 17 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import math
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
# Known amont/aval bétoire station coordinates (from station_list.csv)
|
| 24 |
+
BETOIRE_STATIONS = {
|
| 25 |
+
"H605641101": ("Ajou [amont bétoire]", 48.98492, 0.78902),
|
| 26 |
+
"H605641201": ("Grosley-sur-Risle [aval bétoire]", 49.04707, 0.79984),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
| 31 |
+
R = 6371.0
|
| 32 |
+
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
| 33 |
+
dlat, dlon = lat2 - lat1, lon2 - lon1
|
| 34 |
+
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
| 35 |
+
return R * 2 * math.asin(math.sqrt(a))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _feature_min_distance_km(feature: dict, lat: float, lon: float) -> float:
|
| 39 |
+
"""Minimum distance from (lat, lon) to any vertex of a GeoJSON
|
| 40 |
+
LineString/MultiLineString/Point feature. Vertex-level, not true
|
| 41 |
+
point-to-line distance, but plenty precise at this scale given
|
| 42 |
+
typical vertex spacing."""
|
| 43 |
+
geom = feature.get("geometry") or {}
|
| 44 |
+
gtype = geom.get("type")
|
| 45 |
+
coords = geom.get("coordinates")
|
| 46 |
+
if coords is None:
|
| 47 |
+
return float("inf")
|
| 48 |
+
|
| 49 |
+
def flatten(c, depth):
|
| 50 |
+
if depth == 0:
|
| 51 |
+
yield c
|
| 52 |
+
else:
|
| 53 |
+
for sub in c:
|
| 54 |
+
yield from flatten(sub, depth - 1)
|
| 55 |
+
|
| 56 |
+
depth = {"Point": 0, "LineString": 1, "MultiLineString": 2,
|
| 57 |
+
"Polygon": 2, "MultiPolygon": 3}.get(gtype)
|
| 58 |
+
if depth is None:
|
| 59 |
+
return float("inf")
|
| 60 |
+
|
| 61 |
+
best = float("inf")
|
| 62 |
+
for pt in flatten(coords, depth):
|
| 63 |
+
plon, plat = pt[0], pt[1]
|
| 64 |
+
d = _haversine_km(lat, lon, plat, plon)
|
| 65 |
+
if d < best:
|
| 66 |
+
best = d
|
| 67 |
+
return best
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def check_karst_near_betoire(
|
| 71 |
+
surface_hydro_path: Path,
|
| 72 |
+
search_radius_km: float = 3.0,
|
| 73 |
+
) -> None:
|
| 74 |
+
"""
|
| 75 |
+
Search surface_hydrographique.geojson for any feature whose `Nature`
|
| 76 |
+
property mentions karst, within `search_radius_km` of either bétoire
|
| 77 |
+
station. Prints a clear verdict either way -- this is the check that
|
| 78 |
+
actually answers whether the bétoire naming is backed by a mapped
|
| 79 |
+
feature or just a station-naming convention.
|
| 80 |
+
"""
|
| 81 |
+
data = json.loads(surface_hydro_path.read_text())
|
| 82 |
+
features = data.get("features", [])
|
| 83 |
+
print(f"Loaded {len(features)} feature(s) from {surface_hydro_path}")
|
| 84 |
+
|
| 85 |
+
karst_features = []
|
| 86 |
+
for f in features:
|
| 87 |
+
nature = (f.get("properties") or {}).get("nature") or (f.get("properties") or {}).get("Nature") or ""
|
| 88 |
+
if "karst" in str(nature).lower():
|
| 89 |
+
karst_features.append(f)
|
| 90 |
+
|
| 91 |
+
print(f"Features with 'karst' in Nature: {len(karst_features)}")
|
| 92 |
+
if not karst_features:
|
| 93 |
+
print()
|
| 94 |
+
print("VERDICT: No karst-classified surface_hydrographique feature found "
|
| 95 |
+
"anywhere in the downloaded area. Either this stretch isn't tagged "
|
| 96 |
+
"as karst in BD TOPO (the 'bétoire' naming may be informal/historical "
|
| 97 |
+
"rather than a currently-mapped classification), or the Nature "
|
| 98 |
+
"property uses different wording than expected -- worth printing a "
|
| 99 |
+
"few sample Nature values to check (see below).")
|
| 100 |
+
sample_natures = {(f.get("properties") or {}).get("nature") or (f.get("properties") or {}).get("Nature")
|
| 101 |
+
for f in features[:200]}
|
| 102 |
+
print(f"Sample Nature values seen in this file: {sorted(str(n) for n in sample_natures if n)[:20]}")
|
| 103 |
+
return
|
| 104 |
+
|
| 105 |
+
print()
|
| 106 |
+
for f in karst_features:
|
| 107 |
+
props = f.get("properties", {})
|
| 108 |
+
for code, (name, lat, lon) in BETOIRE_STATIONS.items():
|
| 109 |
+
dist = _feature_min_distance_km(f, lat, lon)
|
| 110 |
+
flag = " <-- WITHIN SEARCH RADIUS" if dist <= search_radius_km else ""
|
| 111 |
+
print(f" Karst feature {props.get('nature', props.get('Nature'))!r} "
|
| 112 |
+
f"is {dist:.2f} km from {code} ({name}){flag}")
|
| 113 |
+
|
| 114 |
+
close = [f for f in karst_features
|
| 115 |
+
for code, (name, lat, lon) in BETOIRE_STATIONS.items()
|
| 116 |
+
if _feature_min_distance_km(f, lat, lon) <= search_radius_km]
|
| 117 |
+
print()
|
| 118 |
+
if close:
|
| 119 |
+
print(f"VERDICT: CONFIRMED -- {len(close)} karst-classified feature(s) found "
|
| 120 |
+
f"within {search_radius_km} km of the bétoire stations. The station "
|
| 121 |
+
f"naming is backed by an actual mapped karst feature, not just "
|
| 122 |
+
f"historical naming convention.")
|
| 123 |
+
else:
|
| 124 |
+
nearest = min(
|
| 125 |
+
(_feature_min_distance_km(f, lat, lon), code)
|
| 126 |
+
for f in karst_features
|
| 127 |
+
for code, (name, lat, lon) in BETOIRE_STATIONS.items()
|
| 128 |
+
)
|
| 129 |
+
print(f"VERDICT: Karst features exist in this area, but the nearest one to "
|
| 130 |
+
f"a bétoire station is {nearest[0]:.2f} km away (from {nearest[1]}) -- "
|
| 131 |
+
f"outside the {search_radius_km} km search radius. Consider widening "
|
| 132 |
+
f"--search-radius-km if this still seems plausibly related.")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def export_real_centerline(
|
| 136 |
+
troncon_path: Path,
|
| 137 |
+
river_name: str,
|
| 138 |
+
output_path: Path,
|
| 139 |
+
name_field_candidates=("cpx_toponyme_de_cours_d_eau", "toponyme", "nom_cours_d_eau"),
|
| 140 |
+
) -> None:
|
| 141 |
+
"""
|
| 142 |
+
Filter troncon_hydrographique to the tronçons belonging to a named
|
| 143 |
+
river, merge and order them into a single sequence, and write out a
|
| 144 |
+
centerline CSV in the same [seq, longitude, latitude] shape as our
|
| 145 |
+
digitized centerlines -- so this can be a drop-in replacement with
|
| 146 |
+
real metric-precision geometry instead of the ~4km-accurate traced
|
| 147 |
+
version.
|
| 148 |
+
|
| 149 |
+
Requires geopandas + shapely.
|
| 150 |
+
"""
|
| 151 |
+
try:
|
| 152 |
+
import geopandas as gpd
|
| 153 |
+
import networkx as nx
|
| 154 |
+
except ImportError as e:
|
| 155 |
+
raise ImportError("export_real_centerline needs geopandas and networkx: "
|
| 156 |
+
"pip install geopandas networkx") from e
|
| 157 |
+
|
| 158 |
+
gdf = gpd.read_file(troncon_path)
|
| 159 |
+
print(f"Loaded {len(gdf)} tronçons from {troncon_path}")
|
| 160 |
+
print(f"Available columns: {list(gdf.columns)}")
|
| 161 |
+
|
| 162 |
+
name_field = next((c for c in name_field_candidates if c in gdf.columns), None)
|
| 163 |
+
if name_field is None:
|
| 164 |
+
raise ValueError(
|
| 165 |
+
f"None of {name_field_candidates} found in columns. "
|
| 166 |
+
f"Inspect gdf.columns and pass the right one manually."
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
river_gdf = gdf[gdf[name_field].astype(str).str.contains(river_name, case=False, na=False)]
|
| 170 |
+
print(f"Matched {len(river_gdf)} tronçon(s) for river name '{river_name}' "
|
| 171 |
+
f"(field: {name_field})")
|
| 172 |
+
if river_gdf.empty:
|
| 173 |
+
sample = gdf[name_field].dropna().unique()[:20]
|
| 174 |
+
print(f"No matches. Sample values in '{name_field}': {list(sample)}")
|
| 175 |
+
return
|
| 176 |
+
|
| 177 |
+
# Build a graph from tronçon endpoints to find the correct traversal
|
| 178 |
+
# order (a tronçon's own row order in the file is not guaranteed to
|
| 179 |
+
# follow the river's course -- same lesson as station_list.csv).
|
| 180 |
+
G = nx.Graph()
|
| 181 |
+
for _, row in river_gdf.iterrows():
|
| 182 |
+
coords = [(c[0], c[1]) for c in row.geometry.coords] # drop Z (altitude) if present
|
| 183 |
+
start, end = coords[0], coords[-1]
|
| 184 |
+
G.add_edge(start, end, coords=coords)
|
| 185 |
+
|
| 186 |
+
if G.number_of_nodes() == 0:
|
| 187 |
+
print("No valid geometry found.")
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
components = list(nx.connected_components(G))
|
| 191 |
+
if len(components) > 1:
|
| 192 |
+
print(f"Warning: river tronçons form {len(components)} disconnected "
|
| 193 |
+
f"component(s) -- using the largest one ({max(len(c) for c in components)} nodes). "
|
| 194 |
+
f"This can happen at basin edges or with name-matching gaps.")
|
| 195 |
+
main_component = max(components, key=len)
|
| 196 |
+
subG = G.subgraph(main_component)
|
| 197 |
+
|
| 198 |
+
# Walk the longest path through the component (same double-BFS
|
| 199 |
+
# technique used for the digitized centerline extraction).
|
| 200 |
+
start_node = next(iter(subG.nodes))
|
| 201 |
+
lengths = nx.single_source_shortest_path_length(subG, start_node)
|
| 202 |
+
a = max(lengths, key=lengths.get)
|
| 203 |
+
lengths2 = nx.single_source_shortest_path_length(subG, a)
|
| 204 |
+
b = max(lengths2, key=lengths2.get)
|
| 205 |
+
node_path = nx.shortest_path(subG, a, b)
|
| 206 |
+
|
| 207 |
+
all_coords = []
|
| 208 |
+
for u, v in zip(node_path[:-1], node_path[1:]):
|
| 209 |
+
edge_coords = subG.edges[u, v]["coords"]
|
| 210 |
+
if edge_coords[0] != u:
|
| 211 |
+
edge_coords = list(reversed(edge_coords))
|
| 212 |
+
all_coords.extend(edge_coords if not all_coords else edge_coords[1:])
|
| 213 |
+
|
| 214 |
+
import pandas as pd
|
| 215 |
+
result = pd.DataFrame(all_coords, columns=["longitude", "latitude"])
|
| 216 |
+
result.insert(0, "seq", range(len(result)))
|
| 217 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 218 |
+
result.to_csv(output_path, index=False)
|
| 219 |
+
print(f"Saved {len(result)}-point real centerline for '{river_name}' to {output_path}")
|
| 220 |
+
print("This is real metric-precision BD TOPO geometry -- replace the old "
|
| 221 |
+
"digitized version by pointing centerline_dir at this file.")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def main() -> None:
|
| 225 |
+
parser = argparse.ArgumentParser(description="Analyze BD TOPO hydrography")
|
| 226 |
+
parser.add_argument("--data-dir", type=Path, default=Path("datasets/bdtopo_hydro"))
|
| 227 |
+
parser.add_argument("--check-karst", action="store_true")
|
| 228 |
+
parser.add_argument("--search-radius-km", type=float, default=3.0)
|
| 229 |
+
parser.add_argument("--export-centerline", type=str, default=None,
|
| 230 |
+
help="River name to extract, e.g. 'Risle' or 'Eure'")
|
| 231 |
+
parser.add_argument("--output", type=Path, default=None)
|
| 232 |
+
args = parser.parse_args()
|
| 233 |
+
|
| 234 |
+
if args.check_karst:
|
| 235 |
+
check_karst_near_betoire(args.data_dir / "surface_hydrographique.geojson",
|
| 236 |
+
args.search_radius_km)
|
| 237 |
+
|
| 238 |
+
if args.export_centerline:
|
| 239 |
+
out = args.output or Path(f"datasets/centerlines/{args.export_centerline.lower()}_centerline_real.csv")
|
| 240 |
+
export_real_centerline(args.data_dir / "troncon_hydrographique.geojson",
|
| 241 |
+
args.export_centerline, out)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
if __name__ == "__main__":
|
| 245 |
+
main()
|
scripts/analysis/check_cavity_proximity.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Real, independent karst cross-check for every real gauge station, using
|
| 3 |
+
the actual raw BDCavités data -- not the precomputed distance_to_
|
| 4 |
+
nearest_cavity_km column, which turned out to never have been saved
|
| 5 |
+
anywhere in the current reach-graph pipeline (confirmed directly: the
|
| 6 |
+
column is absent from both real enriched node files, despite the raw
|
| 7 |
+
cavité data itself existing on disk).
|
| 8 |
+
|
| 9 |
+
Built specifically because the current karst/losing-reach flag
|
| 10 |
+
(verified_continuous=False on 3 real edges of La Risle's bétoire
|
| 11 |
+
stretch) is a hardcoded list based on real station naming evidence
|
| 12 |
+
("[amont bétoire]"/"[aval bétoire]"), not a systematic scan -- it can
|
| 13 |
+
only ever confirm what was already suspected from names, never surface
|
| 14 |
+
a DIFFERENT real station that also sits near a real cavity but never
|
| 15 |
+
got a "bétoire"-style name. This script is that systematic scan.
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
python -m scripts.analysis.check_cavity_proximity --data-root datasets
|
| 19 |
+
"""
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import List, Optional
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _haversine_km_vec(lat1, lon1, lat2, lon2):
|
| 30 |
+
"""Real haversine distance, vectorized -- same formula used
|
| 31 |
+
throughout this project's other real distance computations."""
|
| 32 |
+
R = 6371.0
|
| 33 |
+
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
|
| 34 |
+
dlat, dlon = lat2 - lat1, lon2 - lon1
|
| 35 |
+
a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
|
| 36 |
+
return R * 2 * np.arcsin(np.sqrt(a))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_real_cavity_coordinates(geojson_path: Path, allowed_types: Optional[List[str]] = None) -> np.ndarray:
|
| 40 |
+
"""
|
| 41 |
+
Extracts real [lat, lon] pairs directly from the raw BDCavités
|
| 42 |
+
GeoJSON -- point features only, skipping anything malformed rather
|
| 43 |
+
than crashing the whole load on a single bad real record (large
|
| 44 |
+
real government datasets do have occasional genuine data-quality
|
| 45 |
+
issues; skip-and-count is more useful here than a hard failure).
|
| 46 |
+
|
| 47 |
+
allowed_types, when given, filters on the real "type_cavite" field.
|
| 48 |
+
Confirmed as a real, necessary filter, not an optional refinement:
|
| 49 |
+
an unfiltered first run showed 24 of 27 real stations within 1km of
|
| 50 |
+
*some* cavity, with counts in the thousands -- because BDCavités
|
| 51 |
+
catalogs every cavity type, and the real breakdown (confirmed
|
| 52 |
+
directly against this project's actual file) is dominated by
|
| 53 |
+
quarries (carrière, 21,813), civil/military structures (ouvrage
|
| 54 |
+
civil/militaire, ~9,900), and cellars (cave, 3,990) -- none
|
| 55 |
+
hydrologically meaningful. Only "naturelle" (8,313) corresponds to
|
| 56 |
+
genuine natural cavities, the category that could actually indicate
|
| 57 |
+
real karst/water-loss connectivity. "indéterminé" (18,349) is
|
| 58 |
+
genuinely ambiguous -- could hide real unclassified natural
|
| 59 |
+
features or unclassified quarries -- so it's deliberately not
|
| 60 |
+
included by default; check separately with it included rather than
|
| 61 |
+
assume either way.
|
| 62 |
+
"""
|
| 63 |
+
with open(geojson_path) as f:
|
| 64 |
+
data = json.load(f)
|
| 65 |
+
|
| 66 |
+
coords = []
|
| 67 |
+
n_skipped = 0
|
| 68 |
+
n_filtered = 0
|
| 69 |
+
for feature in data.get("features", []):
|
| 70 |
+
geom = feature.get("geometry")
|
| 71 |
+
if geom is None or geom.get("type") != "Point":
|
| 72 |
+
n_skipped += 1
|
| 73 |
+
continue
|
| 74 |
+
if allowed_types is not None:
|
| 75 |
+
real_type = feature.get("properties", {}).get("type_cavite")
|
| 76 |
+
if real_type not in allowed_types:
|
| 77 |
+
n_filtered += 1
|
| 78 |
+
continue
|
| 79 |
+
lon, lat = geom["coordinates"][0], geom["coordinates"][1]
|
| 80 |
+
coords.append((lat, lon))
|
| 81 |
+
|
| 82 |
+
filter_msg = f", {n_filtered} filtered out by type" if allowed_types is not None else ""
|
| 83 |
+
print(f"Loaded {len(coords)} real cavity point(s) from {geojson_path.name} "
|
| 84 |
+
f"({n_skipped} non-point/malformed feature(s) skipped{filter_msg})")
|
| 85 |
+
return np.array(coords)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def check_cavity_proximity(stations_df: pd.DataFrame, cavity_coords: np.ndarray, radius_km: float = 20.0) -> pd.DataFrame:
|
| 89 |
+
"""
|
| 90 |
+
Real nearest-cavity distance and real cavity count within radius_km,
|
| 91 |
+
per real station -- same two metrics documented for the (never
|
| 92 |
+
actually saved) distance_to_nearest_cavity_km / n_cavities_within_
|
| 93 |
+
20km features, computed fresh here directly against the real raw
|
| 94 |
+
data.
|
| 95 |
+
"""
|
| 96 |
+
cav_lat, cav_lon = cavity_coords[:, 0], cavity_coords[:, 1]
|
| 97 |
+
results = []
|
| 98 |
+
for _, row in stations_df.iterrows():
|
| 99 |
+
dists = _haversine_km_vec(row["latitude"], row["longitude"], cav_lat, cav_lon)
|
| 100 |
+
nearest_km = float(dists.min())
|
| 101 |
+
n_within_radius = int((dists <= radius_km).sum())
|
| 102 |
+
results.append({
|
| 103 |
+
"station_code": row["station_code"],
|
| 104 |
+
"distance_to_nearest_cavity_km": nearest_km,
|
| 105 |
+
"n_cavities_within_20km": n_within_radius,
|
| 106 |
+
})
|
| 107 |
+
return pd.DataFrame(results).sort_values("distance_to_nearest_cavity_km")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def main() -> None:
|
| 111 |
+
parser = argparse.ArgumentParser(description="Real cavity-proximity cross-check for every real gauge station")
|
| 112 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 113 |
+
parser.add_argument("--stations-path", type=Path, default=None)
|
| 114 |
+
parser.add_argument("--close-threshold-km", type=float, default=1.0,
|
| 115 |
+
help="Real distance below which a station is flagged as plausibly karst-connected.")
|
| 116 |
+
args = parser.parse_args()
|
| 117 |
+
|
| 118 |
+
stations_path = args.stations_path or (args.data_root / "station_elevations.csv")
|
| 119 |
+
stations_df = pd.read_csv(stations_path)
|
| 120 |
+
|
| 121 |
+
geojson_path = args.data_root / "bdcavites" / "cavite_localisee.geojson"
|
| 122 |
+
if not geojson_path.exists():
|
| 123 |
+
print(f"No real cavity file found at {geojson_path} -- run "
|
| 124 |
+
f"scripts.data_acquisition.download_bdcavites first.")
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
# Two real, confirmed-necessary filtered runs, not one -- an
|
| 128 |
+
# unfiltered check showed 24/27 real stations within 1km of *some*
|
| 129 |
+
# cavity (counts in the thousands), because BDCavités catalogs
|
| 130 |
+
# every cavity type and quarries/civil structures/cellars dominate
|
| 131 |
+
# the real breakdown. "naturelle" is the conservative, defensible
|
| 132 |
+
# filter; "naturelle + indéterminé" is a broader one that also
|
| 133 |
+
# captures any real natural feature that was never properly typed
|
| 134 |
+
# -- reported side by side rather than picking one and hiding the
|
| 135 |
+
# other's disagreement.
|
| 136 |
+
for label, allowed_types in [
|
| 137 |
+
("naturelle only (conservative)", ["naturelle"]),
|
| 138 |
+
("naturelle + indéterminé (broader)", ["naturelle", "indéterminé"]),
|
| 139 |
+
]:
|
| 140 |
+
print()
|
| 141 |
+
print("=" * 70)
|
| 142 |
+
print(f"Filter: {label}")
|
| 143 |
+
print("=" * 70)
|
| 144 |
+
cavity_coords = load_real_cavity_coordinates(geojson_path, allowed_types=allowed_types)
|
| 145 |
+
if len(cavity_coords) == 0:
|
| 146 |
+
print("Zero real cavities matched this filter -- nothing to check against.")
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
result_df = check_cavity_proximity(stations_df, cavity_coords)
|
| 150 |
+
print(result_df.to_string(index=False))
|
| 151 |
+
|
| 152 |
+
close = result_df[result_df["distance_to_nearest_cavity_km"] < args.close_threshold_km]
|
| 153 |
+
print()
|
| 154 |
+
if len(close) > 0:
|
| 155 |
+
print(f"Station(s) within {args.close_threshold_km}km of a real, filtered cavity "
|
| 156 |
+
f"(cross-check against the known bétoire stations -- H605641101, H605022010, "
|
| 157 |
+
f"H605641401, H605641201 -- anything else here is a real, new candidate):")
|
| 158 |
+
print(close.to_string(index=False))
|
| 159 |
+
else:
|
| 160 |
+
print(f"No real station within {args.close_threshold_km}km of a real, filtered cavity.")
|
| 161 |
+
|
| 162 |
+
output_path = args.data_root / f"cavity_proximity_check_{allowed_types[0]}.csv"
|
| 163 |
+
result_df.to_csv(output_path, index=False)
|
| 164 |
+
print(f"Saved to {output_path}")
|
| 165 |
+
print(f"\nSaved to {output_path}")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
main()
|
scripts/analysis/check_forecast_lead5_anomaly.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Direct check of the raw previous_runs_forecast.csv archive, independent
|
| 3 |
+
of the model/evaluation pipeline -- built specifically because
|
| 4 |
+
evaluate_flood_detection.py's --dump-fp-details showed something
|
| 5 |
+
systematic and graph-wide at the 5-day horizon (185/185 real test
|
| 6 |
+
examples produced at least one real false positive there, often
|
| 7 |
+
several stations simultaneously, including stations on different
|
| 8 |
+
rivers with no surface connection). --check-forecast-fp-correlation
|
| 9 |
+
already ruled out forecast AVAILABILITY as the driver; this checks
|
| 10 |
+
whether the real forecast VALUES themselves are anomalous specifically
|
| 11 |
+
at lead time 5, compared to the other real lead times in the same file.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
python -m scripts.analysis.check_forecast_lead5_anomaly --data-root datasets
|
| 15 |
+
"""
|
| 16 |
+
import argparse
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
FORECAST_LEAD_TIMES = [1, 2, 3, 5, 7] # matches dynamic_features.py's real, confirmed coverage
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def summarize_lead_time_column(df: pd.DataFrame, lead_time: int) -> dict:
|
| 26 |
+
"""
|
| 27 |
+
Real summary statistics for one real precipitation_previous_dayN
|
| 28 |
+
column -- real coverage, real central tendency, real spread, and
|
| 29 |
+
explicit counts of two real, concrete anomaly types (negative
|
| 30 |
+
precipitation, which is physically impossible, and implausibly
|
| 31 |
+
extreme values) rather than just eyeballing a mean/max pair.
|
| 32 |
+
"""
|
| 33 |
+
col = f"precipitation_previous_day{lead_time}"
|
| 34 |
+
if col not in df.columns:
|
| 35 |
+
return {"lead_time": lead_time, "column_present": False}
|
| 36 |
+
|
| 37 |
+
values = df[col].dropna()
|
| 38 |
+
n_negative = int((values < 0).sum())
|
| 39 |
+
# A real, extreme daily precipitation threshold -- the wettest days
|
| 40 |
+
# ever recorded anywhere are on the order of a few hundred mm; used
|
| 41 |
+
# here only to flag values worth a second look, not as a hard
|
| 42 |
+
# physical cutoff.
|
| 43 |
+
n_extreme = int((values > 300).sum())
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"lead_time": lead_time,
|
| 47 |
+
"column_present": True,
|
| 48 |
+
"n_real": len(values),
|
| 49 |
+
"mean": float(values.mean()) if len(values) else float("nan"),
|
| 50 |
+
"median": float(values.median()) if len(values) else float("nan"),
|
| 51 |
+
"std": float(values.std()) if len(values) else float("nan"),
|
| 52 |
+
"min": float(values.min()) if len(values) else float("nan"),
|
| 53 |
+
"max": float(values.max()) if len(values) else float("nan"),
|
| 54 |
+
"n_negative": n_negative,
|
| 55 |
+
"n_extreme_gt_300mm": n_extreme,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def main() -> None:
|
| 60 |
+
parser = argparse.ArgumentParser(description="Check the real ECMWF forecast archive for a lead-time-5-specific anomaly")
|
| 61 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 62 |
+
parser.add_argument("--forecast-path", type=Path, default=None,
|
| 63 |
+
help="Defaults to <data-root>/previous_runs_forecast.csv")
|
| 64 |
+
args = parser.parse_args()
|
| 65 |
+
|
| 66 |
+
forecast_path = args.forecast_path or (args.data_root / "previous_runs_forecast.csv")
|
| 67 |
+
if not forecast_path.exists():
|
| 68 |
+
print(f"No real forecast archive found at {forecast_path}.")
|
| 69 |
+
return
|
| 70 |
+
|
| 71 |
+
df = pd.read_csv(forecast_path)
|
| 72 |
+
print(f"Loaded {len(df)} real row(s) from {forecast_path}")
|
| 73 |
+
print()
|
| 74 |
+
|
| 75 |
+
rows = []
|
| 76 |
+
for lead_time in FORECAST_LEAD_TIMES:
|
| 77 |
+
summary = summarize_lead_time_column(df, lead_time)
|
| 78 |
+
rows.append(summary)
|
| 79 |
+
|
| 80 |
+
result_df = pd.DataFrame(rows)
|
| 81 |
+
print("=" * 70)
|
| 82 |
+
print("Real forecast precipitation, by lead time (mm)")
|
| 83 |
+
print("=" * 70)
|
| 84 |
+
print(result_df.to_string(index=False))
|
| 85 |
+
|
| 86 |
+
present = result_df[result_df["column_present"]]
|
| 87 |
+
if len(present) < 2:
|
| 88 |
+
print("\nFewer than 2 real lead-time columns present -- nothing to compare.")
|
| 89 |
+
return
|
| 90 |
+
|
| 91 |
+
if 5 in present["lead_time"].values:
|
| 92 |
+
lead5 = present[present["lead_time"] == 5].iloc[0]
|
| 93 |
+
others = present[present["lead_time"] != 5]
|
| 94 |
+
others_mean_of_means = others["mean"].mean()
|
| 95 |
+
others_mean_of_maxes = others["max"].mean()
|
| 96 |
+
print(f"\nLead time 5 mean ({lead5['mean']:.2f}mm) vs. real average of other lead times' means "
|
| 97 |
+
f"({others_mean_of_means:.2f}mm)")
|
| 98 |
+
print(f"Lead time 5 max ({lead5['max']:.2f}mm) vs. real average of other lead times' maxes "
|
| 99 |
+
f"({others_mean_of_maxes:.2f}mm)")
|
| 100 |
+
print(f"Lead time 5 negative values: {int(lead5['n_negative'])}; extreme (>300mm) values: "
|
| 101 |
+
f"{int(lead5['n_extreme_gt_300mm'])}")
|
| 102 |
+
|
| 103 |
+
mean_ratio = lead5["mean"] / others_mean_of_means if others_mean_of_means > 0 else float("inf")
|
| 104 |
+
max_ratio = lead5["max"] / others_mean_of_maxes if others_mean_of_maxes > 0 else float("inf")
|
| 105 |
+
if mean_ratio > 2.0 or max_ratio > 2.0 or lead5["n_negative"] > 0:
|
| 106 |
+
print(f"\nLead time 5 shows a real, meaningful anomaly relative to the other real lead times "
|
| 107 |
+
f"(mean ratio {mean_ratio:.2f}x, max ratio {max_ratio:.2f}x) -- real, direct evidence "
|
| 108 |
+
f"worth investigating further as the actual cause of the day-5 false-positive pattern.")
|
| 109 |
+
else:
|
| 110 |
+
print(f"\nLead time 5 does NOT show a meaningful anomaly relative to the other real lead "
|
| 111 |
+
f"times (mean ratio {mean_ratio:.2f}x, max ratio {max_ratio:.2f}x) -- the raw forecast "
|
| 112 |
+
f"values themselves aren't the explanation; the real cause likely lies in how this "
|
| 113 |
+
f"lead time's data is being consumed downstream (e.g. the anchor/target-date mapping, "
|
| 114 |
+
f"or the model's own horizon-conditioning for h=5 specifically), not the raw archive.")
|
| 115 |
+
else:
|
| 116 |
+
print("\nLead time 5 column not present in this real file -- can't run the real comparison.")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
main()
|
scripts/analysis/cross_check_catchments.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Spatially match each station to the catchment polygon that actually
|
| 3 |
+
contains it (point-in-polygon), rather than guessing at a shared ID
|
| 4 |
+
field across BD TOPO and Hub'Eau. This is the correct approach when
|
| 5 |
+
two datasets don't share a clean join key.
|
| 6 |
+
"""
|
| 7 |
+
import sys
|
| 8 |
+
sys.path.insert(0, ".")
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import geopandas as gpd
|
| 12 |
+
|
| 13 |
+
from src.data.loaders.bdtopo_hydro import BDTopoHydroLoader
|
| 14 |
+
|
| 15 |
+
loader = BDTopoHydroLoader(data_path=Path("datasets/bdtopo_hydro"))
|
| 16 |
+
catchments = loader.load_catchments()
|
| 17 |
+
catchments_metric = catchments.to_crs(epsg=2154) # Lambert-93 for accurate area in meters
|
| 18 |
+
catchments["area_km2"] = catchments_metric.geometry.area / 1e6
|
| 19 |
+
|
| 20 |
+
stations = pd.read_csv("datasets/station_elevations.csv")
|
| 21 |
+
hubeau = pd.read_csv("datasets/catchment_area.csv").dropna(subset=["catchment_area_km2"])
|
| 22 |
+
|
| 23 |
+
stations_gdf = gpd.GeoDataFrame(
|
| 24 |
+
stations,
|
| 25 |
+
geometry=gpd.points_from_xy(stations["longitude"], stations["latitude"]),
|
| 26 |
+
crs="EPSG:4326",
|
| 27 |
+
).to_crs(catchments.crs)
|
| 28 |
+
|
| 29 |
+
# Spatial join: which catchment polygon contains each station?
|
| 30 |
+
joined = gpd.sjoin(stations_gdf, catchments[["area_km2", "toponyme", "geometry"]],
|
| 31 |
+
how="left", predicate="within")
|
| 32 |
+
|
| 33 |
+
result = joined[["station_code", "toponyme", "area_km2"]].rename(
|
| 34 |
+
columns={"area_km2": "bdtopo_area_km2", "toponyme": "bdtopo_catchment_name"}
|
| 35 |
+
)
|
| 36 |
+
result = result.merge(hubeau[["station_code", "catchment_area_km2"]], on="station_code", how="left")
|
| 37 |
+
result = result.rename(columns={"catchment_area_km2": "hubeau_area_km2"})
|
| 38 |
+
result["ratio"] = result["bdtopo_area_km2"] / result["hubeau_area_km2"]
|
| 39 |
+
|
| 40 |
+
print(result.to_string(index=False))
|
| 41 |
+
print()
|
| 42 |
+
matched = result.dropna(subset=["bdtopo_area_km2", "hubeau_area_km2"])
|
| 43 |
+
print(f"Stations with BOTH a containing BD TOPO polygon AND a Hub'Eau value: {len(matched)}")
|
| 44 |
+
if len(matched) > 0:
|
| 45 |
+
print(f"Area ratio (BD TOPO / Hub'Eau) — mean: {matched['ratio'].mean():.2f}, "
|
| 46 |
+
f"median: {matched['ratio'].median():.2f}")
|
| 47 |
+
print("(A ratio near 1.0 across most stations would confirm both sources agree; "
|
| 48 |
+
"large/inconsistent ratios would suggest the polygons represent something "
|
| 49 |
+
"other than the gauge's own catchment -- e.g. a downstream aggregate, or "
|
| 50 |
+
"no polygon actually contains that point due to bbox edge effects.)")
|
scripts/analysis/run_bdtopo_checks.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Run this against your real downloaded BD TOPO data to:
|
| 3 |
+
1. Actually answer the karst/bétoire question with real data.
|
| 4 |
+
2. Cross-validate the new catchment polygons against Hub'Eau's surface_bv
|
| 5 |
+
numbers (from catchment_area.csv, built earlier via download_catchment_area.py).
|
| 6 |
+
"""
|
| 7 |
+
import sys
|
| 8 |
+
sys.path.insert(0, ".")
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from src.data.loaders.bdtopo_hydro import BDTopoHydroLoader
|
| 11 |
+
|
| 12 |
+
loader = BDTopoHydroLoader(data_path=Path("datasets/bdtopo_hydro"))
|
| 13 |
+
|
| 14 |
+
# --- 1. The karst question, for real this time ---
|
| 15 |
+
print("=" * 70)
|
| 16 |
+
print("KARST CHECK near amont/aval bétoire stations")
|
| 17 |
+
print("=" * 70)
|
| 18 |
+
BETOIRE_STATIONS = {
|
| 19 |
+
"H605641101": ("Ajou [amont bétoire]", 48.98492, 0.78902),
|
| 20 |
+
"H605641201": ("Grosley-sur-Risle [aval bétoire]", 49.04707, 0.79984),
|
| 21 |
+
}
|
| 22 |
+
surfaces = loader.load_surfaces()
|
| 23 |
+
any_found = False
|
| 24 |
+
for code, (name, lat, lon) in BETOIRE_STATIONS.items():
|
| 25 |
+
results = loader.check_karst_near_point(lat, lon, search_radius_km=3.0, surfaces_geojson=surfaces)
|
| 26 |
+
print(f"\n{code} ({name}):")
|
| 27 |
+
if results:
|
| 28 |
+
any_found = True
|
| 29 |
+
for r in results:
|
| 30 |
+
print(f" -> {r['nature']!r} at {r['distance_km']:.2f} km")
|
| 31 |
+
else:
|
| 32 |
+
print(" -> no karst-classified feature within 3 km")
|
| 33 |
+
|
| 34 |
+
print()
|
| 35 |
+
if any_found:
|
| 36 |
+
print("VERDICT: CONFIRMED by real BD TOPO data -- the bétoire naming is backed "
|
| 37 |
+
"by an actual mapped karst feature.")
|
| 38 |
+
else:
|
| 39 |
+
print("VERDICT: No karst feature found within 3km. Try a wider search radius, "
|
| 40 |
+
"or this may just be historical/informal naming not reflected in the "
|
| 41 |
+
"current BD TOPO classification.")
|
| 42 |
+
|
| 43 |
+
# --- 2. Cross-validate catchment polygons against Hub'Eau surface_bv ---
|
| 44 |
+
print()
|
| 45 |
+
print("=" * 70)
|
| 46 |
+
print("CATCHMENT POLYGON CROSS-CHECK vs Hub'Eau surface_bv")
|
| 47 |
+
print("=" * 70)
|
| 48 |
+
try:
|
| 49 |
+
import geopandas as gpd
|
| 50 |
+
import pandas as pd
|
| 51 |
+
|
| 52 |
+
catchments = loader.load_catchments()
|
| 53 |
+
print(f"Loaded {len(catchments)} catchment polygon(s)")
|
| 54 |
+
print(f"Columns: {list(catchments.columns)}")
|
| 55 |
+
|
| 56 |
+
hubeau_path = Path("datasets/catchment_area.csv")
|
| 57 |
+
if hubeau_path.exists():
|
| 58 |
+
hubeau = pd.read_csv(hubeau_path).dropna(subset=["catchment_area_km2"])
|
| 59 |
+
print(f"\nLoaded {len(hubeau)} Hub'Eau reference values from {hubeau_path}")
|
| 60 |
+
# Compute polygon areas in km^2 (reproject to a metric CRS for accurate area)
|
| 61 |
+
catchments_metric = catchments.to_crs(epsg=2154) # Lambert-93, standard for France
|
| 62 |
+
catchments["area_km2_computed"] = catchments_metric.geometry.area / 1e6
|
| 63 |
+
print(catchments[["area_km2_computed"]].describe())
|
| 64 |
+
print("\n(Match these against datasets/catchment_area.csv's real Hub'Eau values "
|
| 65 |
+
"by whichever ID field the polygons carry -- check catchments.columns "
|
| 66 |
+
"above for a station/site code to join on.)")
|
| 67 |
+
else:
|
| 68 |
+
print(f"\n{hubeau_path} not found -- skipping cross-check. "
|
| 69 |
+
f"(Run download_catchment_area.py first if you want this comparison.)")
|
| 70 |
+
except ImportError:
|
| 71 |
+
print("geopandas not installed -- skipping catchment polygon check.")
|
scripts/analysis/trace_forecast_lookup.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Directly traces a handful of real false positives (from evaluate_flood_
|
| 3 |
+
detection.py's --dump-fp-details output) through build_forecast_tensor's
|
| 4 |
+
actual real lookup logic, by hand, to check for a real date-convention
|
| 5 |
+
mismatch -- built after noticing something concrete while investigating
|
| 6 |
+
the day-5 false-positive pattern:
|
| 7 |
+
|
| 8 |
+
Every other real place in this project that maps a horizon h to a real
|
| 9 |
+
calendar date uses target_date = anchor + (h - 1) -- confirmed directly
|
| 10 |
+
in both prepare_graph_training_windows (the real training-target
|
| 11 |
+
construction) and evaluate_flood_detection.py's own confusion-matrix
|
| 12 |
+
loop. But build_forecast_tensor uses target_date = anchor + h (no "-1").
|
| 13 |
+
|
| 14 |
+
This means: for horizon h, the real target this project is actually
|
| 15 |
+
predicting is at anchor + (h-1), but the forecast value FED INTO the
|
| 16 |
+
model for that same horizon slot is looked up at anchor + h -- one
|
| 17 |
+
real calendar day later than the thing actually being predicted. This
|
| 18 |
+
script shows exactly what real forecast row/column gets retrieved for
|
| 19 |
+
real (anchor, h=5) pairs pulled from an actual false-positive dump, so
|
| 20 |
+
the real size and direction of this mismatch is visible directly,
|
| 21 |
+
not just asserted.
|
| 22 |
+
|
| 23 |
+
Usage:
|
| 24 |
+
python -m scripts.analysis.trace_forecast_lookup --data-root datasets \
|
| 25 |
+
--fp-details datasets/fp_details_horizon_5.csv --horizon 5
|
| 26 |
+
"""
|
| 27 |
+
import argparse
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import pandas as pd
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def trace_one(anchor: pd.Timestamp, station_code: str, horizon: int, df_indexed: pd.DataFrame) -> dict:
|
| 34 |
+
"""
|
| 35 |
+
Mirrors build_forecast_tensor's real lookup exactly -- same
|
| 36 |
+
target_date/column formula, same real row lookup -- so this shows
|
| 37 |
+
precisely what that function actually retrieves for this real
|
| 38 |
+
(anchor, horizon) pair, not an approximation of it.
|
| 39 |
+
"""
|
| 40 |
+
real_target_date = anchor + pd.Timedelta(days=horizon - 1) # what's ACTUALLY being predicted
|
| 41 |
+
forecast_lookup_date = anchor + pd.Timedelta(days=horizon) # what build_forecast_tensor ACTUALLY queries
|
| 42 |
+
col = f"precipitation_previous_day{horizon}"
|
| 43 |
+
|
| 44 |
+
result = {
|
| 45 |
+
"anchor": anchor.date().isoformat(),
|
| 46 |
+
"real_target_date": real_target_date.date().isoformat(),
|
| 47 |
+
"forecast_lookup_date": forecast_lookup_date.date().isoformat(),
|
| 48 |
+
"date_mismatch_days": (forecast_lookup_date - real_target_date).days,
|
| 49 |
+
"station_code": station_code,
|
| 50 |
+
"column": col,
|
| 51 |
+
}
|
| 52 |
+
try:
|
| 53 |
+
value = df_indexed.loc[(forecast_lookup_date, station_code), col]
|
| 54 |
+
result["forecast_value_actually_used"] = float(value) if pd.notna(value) else None
|
| 55 |
+
except KeyError:
|
| 56 |
+
result["forecast_value_actually_used"] = None
|
| 57 |
+
|
| 58 |
+
# What the forecast value WOULD have been if looked up at the real
|
| 59 |
+
# target date instead, for direct comparison -- not necessarily a
|
| 60 |
+
# "correct" value (that date's own previous_day{h} column reflects
|
| 61 |
+
# a different, (h) days in advance forecast, not h-1), just useful
|
| 62 |
+
# to see whether it's meaningfully different from what's actually used.
|
| 63 |
+
try:
|
| 64 |
+
value_at_real_target = df_indexed.loc[(real_target_date, station_code), col]
|
| 65 |
+
result["value_at_real_target_date_same_column"] = float(value_at_real_target) if pd.notna(value_at_real_target) else None
|
| 66 |
+
except KeyError:
|
| 67 |
+
result["value_at_real_target_date_same_column"] = None
|
| 68 |
+
|
| 69 |
+
return result
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main() -> None:
|
| 73 |
+
parser = argparse.ArgumentParser(description="Trace real forecast lookups for a handful of real false positives")
|
| 74 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 75 |
+
parser.add_argument("--forecast-path", type=Path, default=None,
|
| 76 |
+
help="Defaults to <data-root>/previous_runs_forecast.csv")
|
| 77 |
+
parser.add_argument("--fp-details", type=Path, required=True,
|
| 78 |
+
help="Path to a real fp_details_horizon_N.csv from evaluate_flood_detection.py's --dump-fp-details")
|
| 79 |
+
parser.add_argument("--horizon", type=int, required=True)
|
| 80 |
+
parser.add_argument("--n-samples", type=int, default=10)
|
| 81 |
+
args = parser.parse_args()
|
| 82 |
+
|
| 83 |
+
forecast_path = args.forecast_path or (args.data_root / "previous_runs_forecast.csv")
|
| 84 |
+
if not forecast_path.exists():
|
| 85 |
+
print(f"No real forecast archive found at {forecast_path}.")
|
| 86 |
+
return
|
| 87 |
+
if not args.fp_details.exists():
|
| 88 |
+
print(f"No real false-positive detail file found at {args.fp_details}.")
|
| 89 |
+
return
|
| 90 |
+
|
| 91 |
+
forecast_df = pd.read_csv(forecast_path)
|
| 92 |
+
forecast_df["date"] = pd.to_datetime(forecast_df["date"])
|
| 93 |
+
df_indexed = forecast_df.set_index(["date", "station_code"])
|
| 94 |
+
|
| 95 |
+
fp_df = pd.read_csv(args.fp_details)
|
| 96 |
+
fp_df["target_date"] = pd.to_datetime(fp_df["target_date"])
|
| 97 |
+
# fp_details' own target_date IS the real target date (h-1
|
| 98 |
+
# convention, matching evaluate_flood_detection.py's own confusion-
|
| 99 |
+
# matrix loop) -- recover the real anchor from it directly.
|
| 100 |
+
fp_df["anchor"] = fp_df["target_date"] - pd.Timedelta(days=args.horizon - 1)
|
| 101 |
+
|
| 102 |
+
sample = fp_df.head(args.n_samples)
|
| 103 |
+
print(f"Tracing {len(sample)} real false positive(s) at horizon={args.horizon}d through the real "
|
| 104 |
+
f"forecast lookup logic\n")
|
| 105 |
+
|
| 106 |
+
rows = []
|
| 107 |
+
for _, row in sample.iterrows():
|
| 108 |
+
traced = trace_one(row["anchor"], row["station_code"], args.horizon, df_indexed)
|
| 109 |
+
rows.append(traced)
|
| 110 |
+
|
| 111 |
+
result_df = pd.DataFrame(rows)
|
| 112 |
+
print(result_df.to_string(index=False))
|
| 113 |
+
|
| 114 |
+
n_mismatched_dates = int((result_df["date_mismatch_days"] != 0).sum())
|
| 115 |
+
print(f"\n{n_mismatched_dates}/{len(result_df)} real traced examples show a real date mismatch "
|
| 116 |
+
f"between what's being predicted (real_target_date) and what the forecast is actually "
|
| 117 |
+
f"looked up for (forecast_lookup_date).")
|
| 118 |
+
if n_mismatched_dates > 0:
|
| 119 |
+
print(f"This is a real, systematic {result_df['date_mismatch_days'].iloc[0]}-day offset (from "
|
| 120 |
+
f"build_forecast_tensor's target_date = anchor + h, vs. every other real place in this "
|
| 121 |
+
f"project using anchor + h - 1) -- the model is being conditioned on the forecast for the "
|
| 122 |
+
f"day AFTER what it's actually predicting at this horizon, not the forecast for the real "
|
| 123 |
+
f"target date itself.")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
main()
|
scripts/data_acquisition/__pycache__/fetch_meteofrance_precipitation.cpython-311.pyc
ADDED
|
Binary file (16.7 kB). View file
|
|
|
scripts/data_acquisition/download_bdcavites.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Downloads BDCavités (BRGM's national underground cavity inventory --
|
| 3 |
+
karst sinkholes, quarries, marl pits, natural cavities) for the Eure/
|
| 4 |
+
Risle area, via Géorisques' WFS.
|
| 5 |
+
|
| 6 |
+
Directly relevant to the bétoire investigation: this is an independent,
|
| 7 |
+
purpose-built dataset for exactly this phenomenon, unlike inferring it
|
| 8 |
+
from Hub'Eau station naming or BD TOPO's provisional karst attribute.
|
| 9 |
+
|
| 10 |
+
CONFIRMED (fetched GetCapabilities directly): endpoint
|
| 11 |
+
https://georisques.gouv.fr/services, WFS 1.1.0, typeName
|
| 12 |
+
CAVITE_LOCALISEE ("Cavités souterraines abandonnées d'origine non
|
| 13 |
+
minière"), GeoJSON output supported directly. Axis order for this
|
| 14 |
+
specific server is NOT separately confirmed -- reusing the same
|
| 15 |
+
lon,lat-then-lat,lon retry that scripts/download_bdtopo_hydro.py needed
|
| 16 |
+
for a different WFS server, since different servers have behaved
|
| 17 |
+
differently on this before and there's no reason to assume this one
|
| 18 |
+
won't too.
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
python -m scripts.download_bdcavites --check
|
| 22 |
+
python -m scripts.download_bdcavites
|
| 23 |
+
"""
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import sys
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import requests
|
| 30 |
+
|
| 31 |
+
WFS_URL = "https://georisques.gouv.fr/services"
|
| 32 |
+
TYPE_NAME = "CAVITE_LOCALISEE"
|
| 33 |
+
|
| 34 |
+
# Same widened bbox as scripts/download_bdtopo_hydro.py's BBOX (updated
|
| 35 |
+
# after confirmed real evidence of truncation on the original, tighter
|
| 36 |
+
# box) -- keeping both scripts scoped to the same area.
|
| 37 |
+
BBOX = (-0.1, 47.7, 2.1, 49.9) # (min_lon, min_lat, max_lon, max_lat)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def check_typename() -> bool:
|
| 41 |
+
params = {
|
| 42 |
+
"SERVICE": "WFS", "VERSION": "1.1.0", "REQUEST": "GetFeature",
|
| 43 |
+
"TYPENAME": TYPE_NAME, "MAXFEATURES": 1, "OUTPUTFORMAT": "application/json; subtype=geojson; charset=utf-8",
|
| 44 |
+
}
|
| 45 |
+
resp = requests.get(WFS_URL, params=params, timeout=30)
|
| 46 |
+
ok, detail = _parse_response(resp)
|
| 47 |
+
print(f" {TYPE_NAME}: {'OK' if ok else 'FAILED'}")
|
| 48 |
+
if not ok:
|
| 49 |
+
print(f" {detail}")
|
| 50 |
+
return ok
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _parse_response(resp: "requests.Response"):
|
| 54 |
+
if resp.status_code != 200:
|
| 55 |
+
return False, f"HTTP {resp.status_code}: {resp.text[:400]}"
|
| 56 |
+
try:
|
| 57 |
+
data = resp.json()
|
| 58 |
+
except ValueError:
|
| 59 |
+
return False, f"Response was not JSON (likely a WFS ExceptionReport): {resp.text[:400]}"
|
| 60 |
+
if isinstance(data, dict) and data.get("type") == "FeatureCollection":
|
| 61 |
+
return True, ""
|
| 62 |
+
return False, f"Response was JSON but not a FeatureCollection: {str(data)[:400]}"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def fetch_all_pages(bbox_param: str, page_size: int = 1000) -> list:
|
| 66 |
+
all_features = []
|
| 67 |
+
start_index = 0
|
| 68 |
+
while True:
|
| 69 |
+
params = {
|
| 70 |
+
"SERVICE": "WFS", "VERSION": "1.1.0", "REQUEST": "GetFeature",
|
| 71 |
+
"TYPENAME": TYPE_NAME, "BBOX": bbox_param,
|
| 72 |
+
"OUTPUTFORMAT": "application/json; subtype=geojson; charset=utf-8",
|
| 73 |
+
"MAXFEATURES": page_size, "STARTINDEX": start_index,
|
| 74 |
+
}
|
| 75 |
+
resp = requests.get(WFS_URL, params=params, timeout=60)
|
| 76 |
+
ok, detail = _parse_response(resp)
|
| 77 |
+
if not ok:
|
| 78 |
+
print(f" ERROR at startIndex={start_index}: {detail}")
|
| 79 |
+
break
|
| 80 |
+
data = resp.json()
|
| 81 |
+
features = data.get("features", [])
|
| 82 |
+
if not features:
|
| 83 |
+
break
|
| 84 |
+
all_features.extend(features)
|
| 85 |
+
if len(features) < page_size:
|
| 86 |
+
break
|
| 87 |
+
start_index += page_size
|
| 88 |
+
return all_features
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def fetch_with_axis_retry(bbox: tuple, out_path: Path) -> None:
|
| 92 |
+
min_lon, min_lat, max_lon, max_lat = bbox
|
| 93 |
+
orderings = [
|
| 94 |
+
("lon,lat", f"{min_lon},{min_lat},{max_lon},{max_lat},urn:ogc:def:crs:EPSG::4326"),
|
| 95 |
+
("lat,lon", f"{min_lat},{min_lon},{max_lat},{max_lon},urn:ogc:def:crs:EPSG::4326"),
|
| 96 |
+
]
|
| 97 |
+
features = []
|
| 98 |
+
for label, bbox_param in orderings:
|
| 99 |
+
print(f" trying axis order {label}...")
|
| 100 |
+
features = fetch_all_pages(bbox_param)
|
| 101 |
+
if features:
|
| 102 |
+
print(f" -> {label} worked ({len(features)} feature(s))")
|
| 103 |
+
break
|
| 104 |
+
print(f" -> {label} returned 0 features")
|
| 105 |
+
|
| 106 |
+
geojson = {"type": "FeatureCollection", "features": features}
|
| 107 |
+
out_path.write_text(json.dumps(geojson))
|
| 108 |
+
if features:
|
| 109 |
+
print(f"Saved {len(features)} feature(s) to {out_path}")
|
| 110 |
+
else:
|
| 111 |
+
print(f"Saved an EMPTY file to {out_path} -- both axis orders returned nothing. "
|
| 112 |
+
f"Run --check first, or this area may genuinely have zero recorded cavities "
|
| 113 |
+
f"(plausible -- BDCavités coverage is built department-by-department and "
|
| 114 |
+
f"isn't uniformly complete everywhere).")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def main() -> None:
|
| 118 |
+
parser = argparse.ArgumentParser(description="Download BDCavités for the Eure/Risle area")
|
| 119 |
+
parser.add_argument("--check", action="store_true")
|
| 120 |
+
parser.add_argument("--output-dir", type=Path, default=Path("datasets/bdcavites"))
|
| 121 |
+
args = parser.parse_args()
|
| 122 |
+
|
| 123 |
+
if args.check:
|
| 124 |
+
print("Testing typeName against the live service...")
|
| 125 |
+
ok = check_typename()
|
| 126 |
+
if not ok:
|
| 127 |
+
print("\nFailed -- the typeName or service details may have changed since this "
|
| 128 |
+
"script was written. Fetch GetCapabilities directly to check:")
|
| 129 |
+
print(f" {WFS_URL}?SERVICE=WFS&VERSION=1.1.0&REQUEST=GetCapabilities")
|
| 130 |
+
else:
|
| 131 |
+
print("\nOK. Re-run without --check to download.")
|
| 132 |
+
return
|
| 133 |
+
|
| 134 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 135 |
+
print("Fetching CAVITE_LOCALISEE (BDCavités)...")
|
| 136 |
+
fetch_with_axis_retry(BBOX, args.output_dir / "cavite_localisee.geojson")
|
| 137 |
+
print()
|
| 138 |
+
print("Once downloaded, cross-reference against the amont/aval bétoire stations "
|
| 139 |
+
"(H605641101 at 48.98492,0.78902 and H605641201 at 49.04707,0.79984) the same "
|
| 140 |
+
"way scripts/analyze_bdtopo_hydro.py's check_karst_near_betoire did for the "
|
| 141 |
+
"BD TOPO karst attribute -- this is a genuinely independent second check, "
|
| 142 |
+
"not a re-run of the same one.")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
main()
|
scripts/data_acquisition/download_bdcharm.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Downloads BD Charm-50 (BRGM's harmonized 1:50,000 geological maps) for
|
| 3 |
+
the departments covering the Eure/Risle study area.
|
| 4 |
+
|
| 5 |
+
CONFIRMED (via data.gouv.fr / InfoTerre): free, open (Licence Ouverte),
|
| 6 |
+
no authentication -- direct per-department ZIP download from InfoTerre.
|
| 7 |
+
This is a genuinely different access pattern than the WFS sources
|
| 8 |
+
elsewhere in this project (BD TOPO, BDCavités): no bbox query, no axis-
|
| 9 |
+
order ambiguity, just a fixed URL per department. Real, working URL
|
| 10 |
+
pattern (found directly, not guessed):
|
| 11 |
+
http://infoterre.brgm.fr/telechargements/BDCharm50/GEO050K_HARM_XXX.zip
|
| 12 |
+
where XXX is the 3-digit department code (e.g. 027 for Eure).
|
| 13 |
+
|
| 14 |
+
NOTE: a separate, DIFFERENT distribution of this same underlying dataset
|
| 15 |
+
exists that requires CIGAL network membership (seen for at least Alsace
|
| 16 |
+
regional data) -- that is NOT what this script uses. This script only
|
| 17 |
+
uses the free InfoTerre download confirmed via data.gouv.fr.
|
| 18 |
+
|
| 19 |
+
DEPARTMENTS: 27 (Eure), 61 (Orne), 28 (Eure-et-Loir) -- 27 and 61
|
| 20 |
+
confirmed directly from real INSEE codes seen in this project's own
|
| 21 |
+
station_list.csv (61342, 27040, 27116, 27468); 28 included because the
|
| 22 |
+
Eure's own southern tributaries (Voise, Drouette) run through Chartres/
|
| 23 |
+
Dreux, unambiguously in Eure-et-Loir. Add more department codes via
|
| 24 |
+
--departments if the real geographic extent turns out to need them.
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
python -m scripts.download_bdcharm50
|
| 28 |
+
"""
|
| 29 |
+
import argparse
|
| 30 |
+
import zipfile
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import requests
|
| 34 |
+
|
| 35 |
+
DEFAULT_DEPARTMENTS = ["027", "028", "061"]
|
| 36 |
+
BASE_URL = "http://infoterre.brgm.fr/telechargements/BDCharm50"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def download_department(dept: str, output_dir: Path) -> bool:
|
| 40 |
+
url = f"{BASE_URL}/GEO050K_HARM_{dept}.zip"
|
| 41 |
+
zip_path = output_dir / f"GEO050K_HARM_{dept}.zip"
|
| 42 |
+
|
| 43 |
+
print(f" department {dept}: {url}")
|
| 44 |
+
try:
|
| 45 |
+
resp = requests.get(url, timeout=120, stream=True)
|
| 46 |
+
except requests.RequestException as e:
|
| 47 |
+
print(f" FAILED: {e}")
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
if resp.status_code != 200:
|
| 51 |
+
print(f" FAILED: HTTP {resp.status_code}")
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
content_type = resp.headers.get("Content-Type", "")
|
| 55 |
+
if "zip" not in content_type and "octet-stream" not in content_type:
|
| 56 |
+
# A 200 status with an HTML content-type here usually means an
|
| 57 |
+
# error page or a "department not available" page was returned
|
| 58 |
+
# instead of the real ZIP -- catching this explicitly rather
|
| 59 |
+
# than silently saving an HTML file with a .zip extension.
|
| 60 |
+
print(f" WARNING: Content-Type is {content_type!r}, not zip -- "
|
| 61 |
+
f"this department's file may not exist at this URL. Saving "
|
| 62 |
+
f"anyway for inspection, but verify before trusting it.")
|
| 63 |
+
|
| 64 |
+
zip_path.write_bytes(resp.content)
|
| 65 |
+
size_kb = zip_path.stat().st_size / 1024
|
| 66 |
+
print(f" saved {zip_path} ({size_kb:.0f} KB)")
|
| 67 |
+
|
| 68 |
+
extract_dir = output_dir / f"dept_{dept}"
|
| 69 |
+
try:
|
| 70 |
+
with zipfile.ZipFile(zip_path) as zf:
|
| 71 |
+
zf.extractall(extract_dir)
|
| 72 |
+
shp_files = list(extract_dir.rglob("*.shp"))
|
| 73 |
+
print(f" extracted to {extract_dir} ({len(shp_files)} .shp file(s) found)")
|
| 74 |
+
return True
|
| 75 |
+
except zipfile.BadZipFile:
|
| 76 |
+
print(f" FAILED: downloaded file is not a valid zip -- likely an error page, "
|
| 77 |
+
f"not real data. Check {zip_path} directly.")
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def main() -> None:
|
| 82 |
+
parser = argparse.ArgumentParser(description="Download BD Charm-50 geological maps")
|
| 83 |
+
parser.add_argument("--departments", nargs="+", default=DEFAULT_DEPARTMENTS,
|
| 84 |
+
help="3-digit department codes, e.g. 027 028 061")
|
| 85 |
+
parser.add_argument("--output-dir", type=Path, default=Path("datasets/bdcharm50"))
|
| 86 |
+
args = parser.parse_args()
|
| 87 |
+
|
| 88 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
print(f"Downloading BD Charm-50 for departments: {args.departments}")
|
| 90 |
+
print()
|
| 91 |
+
|
| 92 |
+
results = {}
|
| 93 |
+
for dept in args.departments:
|
| 94 |
+
results[dept] = download_department(dept, args.output_dir)
|
| 95 |
+
print()
|
| 96 |
+
|
| 97 |
+
print("=" * 60)
|
| 98 |
+
ok = [d for d, r in results.items() if r]
|
| 99 |
+
failed = [d for d, r in results.items() if not r]
|
| 100 |
+
print(f"Succeeded: {ok}")
|
| 101 |
+
if failed:
|
| 102 |
+
print(f"Failed: {failed} -- check the URL pattern still matches by visiting "
|
| 103 |
+
f"https://infoterre.brgm.fr/page/telechargement-cartes-geologiques directly")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
main()
|
scripts/data_acquisition/download_bdtopo_hydro.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download BD TOPO hydrography (troncon_hydrographique, surface_hydrographique)
|
| 3 |
+
for the Eure/Risle area via IGN's Geoplateforme WFS, instead of pulling the
|
| 4 |
+
whole national dataset.
|
| 5 |
+
|
| 6 |
+
VERIFICATION NOTE: I confirmed the WFS endpoint itself is real and live
|
| 7 |
+
(https://data.geopf.fr/wfs, WFS 2.0.0) and that a "BDTOPO_V3" namespace
|
| 8 |
+
exists on it, by fetching GetCapabilities directly. I could NOT confirm
|
| 9 |
+
the exact typeName for the hydrography layers within it — the full
|
| 10 |
+
capabilities document is huge (thousands of layers across all IGN
|
| 11 |
+
products) and got truncated before reaching them. DEFAULT_TYPENAMES
|
| 12 |
+
below is my best guess based on IGN's usual naming convention, NOT a
|
| 13 |
+
verified value. This script's first step (check_typenames) tests each
|
| 14 |
+
name with a tiny query before attempting the real extraction, and tells
|
| 15 |
+
you exactly how to find the right name yourself if my guess is wrong.
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
python download_bdtopo_hydro.py --check # verify typeNames first
|
| 19 |
+
python download_bdtopo_hydro.py # then run the real extraction
|
| 20 |
+
"""
|
| 21 |
+
import argparse
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import requests
|
| 26 |
+
import pandas as pd
|
| 27 |
+
|
| 28 |
+
WFS_URL = "https://data.geopf.fr/wfs"
|
| 29 |
+
|
| 30 |
+
# Best-guess typeNames -- see VERIFICATION NOTE above. Override with
|
| 31 |
+
# --troncon-typename / --surface-typename / --catchment-typename if wrong.
|
| 32 |
+
# troncon/surface confirmed correct against the real service (see
|
| 33 |
+
# CONFIRMED note below); catchment is still an unverified guess.
|
| 34 |
+
DEFAULT_TRONCON_TYPENAME = "BDTOPO_V3:troncon_hydrographique"
|
| 35 |
+
DEFAULT_SURFACE_TYPENAME = "BDTOPO_V3:surface_hydrographique"
|
| 36 |
+
DEFAULT_CATCHMENT_TYPENAME = "BDTOPO_V3:bassin_versant_topographique"
|
| 37 |
+
|
| 38 |
+
# CONFIRMED: both troncon_hydrographique and surface_hydrographique
|
| 39 |
+
# typeNames work against the live service (verified in this project's
|
| 40 |
+
# actual run: 30045 tronçons fetched successfully). bassin_versant_
|
| 41 |
+
# topographique has NOT been verified the same way -- run --check
|
| 42 |
+
# before trusting it.
|
| 43 |
+
|
| 44 |
+
# Bounding box around the Eure/Risle stations, in WGS84 (matches the
|
| 45 |
+
# lon/lat range in your real station_list.csv, with a small margin).
|
| 46 |
+
BBOX = (0.3, 48.3, 1.7, 49.5) # (min_lon, min_lat, max_lon, max_lat)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def find_hydro_typenames(keyword: str = "hydrographique") -> list:
|
| 50 |
+
"""
|
| 51 |
+
Fetch GetCapabilities and search for FeatureType <Name> entries
|
| 52 |
+
containing `keyword`. This is the fallback if DEFAULT_*_TYPENAME
|
| 53 |
+
turns out to be wrong -- run this to get the real list.
|
| 54 |
+
"""
|
| 55 |
+
print(f"Fetching WFS GetCapabilities and searching for '{keyword}'...")
|
| 56 |
+
resp = requests.get(WFS_URL, params={
|
| 57 |
+
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetCapabilities",
|
| 58 |
+
}, timeout=60)
|
| 59 |
+
resp.raise_for_status()
|
| 60 |
+
|
| 61 |
+
import re
|
| 62 |
+
names = re.findall(r"<Name>([^<]*)</Name>", resp.text)
|
| 63 |
+
matches = [n for n in names if keyword.lower() in n.lower()]
|
| 64 |
+
print(f"Found {len(matches)} matching layer(s):")
|
| 65 |
+
for m in matches:
|
| 66 |
+
print(f" {m}")
|
| 67 |
+
return matches
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def check_typename(type_name: str) -> bool:
|
| 71 |
+
"""Tiny test query (count=1, no bbox) to confirm a typeName is valid
|
| 72 |
+
before running the real extraction against it."""
|
| 73 |
+
params = {
|
| 74 |
+
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetFeature",
|
| 75 |
+
"TYPENAMES": type_name, "COUNT": 1, "OUTPUTFORMAT": "application/json",
|
| 76 |
+
}
|
| 77 |
+
resp = requests.get(WFS_URL, params=params, timeout=30)
|
| 78 |
+
ok, error_detail = _parse_wfs_response(resp)
|
| 79 |
+
status = "OK" if ok else f"FAILED"
|
| 80 |
+
print(f" {type_name}: {status}")
|
| 81 |
+
if not ok:
|
| 82 |
+
print(f" {error_detail}")
|
| 83 |
+
return ok
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _parse_wfs_response(resp: "requests.Response") -> "tuple[bool, str]":
|
| 87 |
+
"""
|
| 88 |
+
Check whether a WFS response is a real FeatureCollection or an error
|
| 89 |
+
(WFS ExceptionReport, or a non-JSON/HTML error page). Returns
|
| 90 |
+
(is_valid, detail) -- detail is empty on success, otherwise a
|
| 91 |
+
human-readable explanation of what actually came back, so a failure
|
| 92 |
+
is never silently swallowed into "0 features".
|
| 93 |
+
"""
|
| 94 |
+
if resp.status_code != 200:
|
| 95 |
+
return False, f"HTTP {resp.status_code}: {resp.text[:400]}"
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
data = resp.json()
|
| 99 |
+
except ValueError:
|
| 100 |
+
# Not JSON at all -- almost always means an XML ExceptionReport
|
| 101 |
+
# or an HTML error page came back instead.
|
| 102 |
+
return False, f"Response was not JSON (likely a WFS ExceptionReport): {resp.text[:400]}"
|
| 103 |
+
|
| 104 |
+
if isinstance(data, dict) and data.get("type") == "FeatureCollection":
|
| 105 |
+
return True, ""
|
| 106 |
+
|
| 107 |
+
# Some WFS error responses come back as JSON but without a
|
| 108 |
+
# FeatureCollection shape (e.g. {"exceptionText": [...]})
|
| 109 |
+
return False, f"Response was JSON but not a FeatureCollection: {str(data)[:400]}"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def fetch_features(type_name: str, bbox: tuple, out_path: Path, srs: str = "EPSG:4326") -> None:
|
| 113 |
+
"""
|
| 114 |
+
GetFeature request over the given bbox, paginated via startIndex
|
| 115 |
+
since a national layer clipped to a small bbox can still exceed the
|
| 116 |
+
server's per-request feature limit.
|
| 117 |
+
|
| 118 |
+
AXIS ORDER: when BBOX specifies its CRS via the URN form
|
| 119 |
+
(urn:ogc:def:crs:EPSG::4326), the OGC spec requires lat,lon axis
|
| 120 |
+
order -- NOT the traditional lon,lat most tools use. Getting this
|
| 121 |
+
backwards doesn't error, it just silently matches zero real-world
|
| 122 |
+
features (a well-known WFS 2.0 gotcha). Rather than guess, this
|
| 123 |
+
tries lon,lat first and automatically retries with lat,lon swapped
|
| 124 |
+
if the first attempt comes back empty, so a silent axis-order bug
|
| 125 |
+
can't hide as "no data in this area" again.
|
| 126 |
+
"""
|
| 127 |
+
min_lon, min_lat, max_lon, max_lat = bbox
|
| 128 |
+
orderings = [
|
| 129 |
+
("lon,lat", f"{min_lon},{min_lat},{max_lon},{max_lat},urn:ogc:def:crs:EPSG::4326"),
|
| 130 |
+
("lat,lon", f"{min_lat},{min_lon},{max_lat},{max_lon},urn:ogc:def:crs:EPSG::4326"),
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
all_features = []
|
| 134 |
+
for label, bbox_param in orderings:
|
| 135 |
+
print(f" trying axis order {label}...")
|
| 136 |
+
all_features = _fetch_paginated(type_name, bbox_param)
|
| 137 |
+
if all_features:
|
| 138 |
+
print(f" -> {label} worked ({len(all_features)} feature(s)). "
|
| 139 |
+
f"Use this axis order for future queries against this endpoint.")
|
| 140 |
+
break
|
| 141 |
+
print(f" -> {label} returned 0 features.")
|
| 142 |
+
|
| 143 |
+
import json
|
| 144 |
+
geojson = {"type": "FeatureCollection", "features": all_features}
|
| 145 |
+
out_path.write_text(json.dumps(geojson))
|
| 146 |
+
if all_features:
|
| 147 |
+
print(f"Saved {len(all_features)} feature(s) to {out_path}")
|
| 148 |
+
else:
|
| 149 |
+
print(f"Saved an EMPTY file to {out_path} -- both axis orders returned "
|
| 150 |
+
f"nothing real. The typeName is valid (confirmed by --check), so this "
|
| 151 |
+
f"suggests the bbox itself doesn't overlap any features in this layer, "
|
| 152 |
+
f"or another parameter is off. Try a much larger bbox (e.g. all of "
|
| 153 |
+
f"France: -5,41,10,51) as a sanity check.")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _fetch_paginated(type_name: str, bbox_param: str) -> list:
|
| 157 |
+
"""One bbox ordering's worth of paginated GetFeature calls."""
|
| 158 |
+
all_features = []
|
| 159 |
+
start_index = 0
|
| 160 |
+
page_size = 1000
|
| 161 |
+
|
| 162 |
+
while True:
|
| 163 |
+
params = {
|
| 164 |
+
"SERVICE": "WFS", "VERSION": "2.0.0", "REQUEST": "GetFeature",
|
| 165 |
+
"TYPENAMES": type_name, "BBOX": bbox_param,
|
| 166 |
+
"OUTPUTFORMAT": "application/json",
|
| 167 |
+
"COUNT": page_size, "STARTINDEX": start_index,
|
| 168 |
+
}
|
| 169 |
+
resp = requests.get(WFS_URL, params=params, timeout=60)
|
| 170 |
+
is_valid, error_detail = _parse_wfs_response(resp)
|
| 171 |
+
if not is_valid:
|
| 172 |
+
print(f" ERROR at startIndex={start_index}: {error_detail}")
|
| 173 |
+
break
|
| 174 |
+
data = resp.json()
|
| 175 |
+
features = data.get("features", [])
|
| 176 |
+
if not features:
|
| 177 |
+
break
|
| 178 |
+
all_features.extend(features)
|
| 179 |
+
if len(features) < page_size:
|
| 180 |
+
break
|
| 181 |
+
start_index += page_size
|
| 182 |
+
|
| 183 |
+
return all_features
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def main() -> None:
|
| 187 |
+
parser = argparse.ArgumentParser(description="Download BD TOPO hydrography for Eure/Risle")
|
| 188 |
+
parser.add_argument("--check", action="store_true",
|
| 189 |
+
help="Only verify typeNames, don't download anything")
|
| 190 |
+
parser.add_argument("--troncon-typename", default=DEFAULT_TRONCON_TYPENAME)
|
| 191 |
+
parser.add_argument("--surface-typename", default=DEFAULT_SURFACE_TYPENAME)
|
| 192 |
+
parser.add_argument("--catchment-typename", default=DEFAULT_CATCHMENT_TYPENAME)
|
| 193 |
+
parser.add_argument("--skip-catchments", action="store_true",
|
| 194 |
+
help="Skip bassin_versant_topographique (untested typeName)")
|
| 195 |
+
parser.add_argument("--output-dir", type=Path, default=Path("datasets/bdtopo_hydro"))
|
| 196 |
+
args = parser.parse_args()
|
| 197 |
+
|
| 198 |
+
if args.check:
|
| 199 |
+
print("=" * 70)
|
| 200 |
+
print("Testing default typeNames...")
|
| 201 |
+
print("=" * 70)
|
| 202 |
+
ok_troncon = check_typename(args.troncon_typename)
|
| 203 |
+
ok_surface = check_typename(args.surface_typename)
|
| 204 |
+
ok_catchment = args.skip_catchments or check_typename(args.catchment_typename)
|
| 205 |
+
if not (ok_troncon and ok_surface and ok_catchment):
|
| 206 |
+
print()
|
| 207 |
+
print("At least one default typeName failed. Searching capabilities "
|
| 208 |
+
"for the real name(s) instead:")
|
| 209 |
+
find_hydro_typenames("hydrographique")
|
| 210 |
+
find_hydro_typenames("bassin_versant")
|
| 211 |
+
print()
|
| 212 |
+
print("Re-run with --troncon-typename / --surface-typename / "
|
| 213 |
+
"--catchment-typename set to whatever the search above found, "
|
| 214 |
+
"then --check again to confirm.")
|
| 215 |
+
else:
|
| 216 |
+
print()
|
| 217 |
+
print("All typeNames OK. Re-run without --check to download the real data.")
|
| 218 |
+
return
|
| 219 |
+
|
| 220 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 221 |
+
|
| 222 |
+
print("Fetching troncon_hydrographique (river centerline geometry + attributes)...")
|
| 223 |
+
fetch_features(args.troncon_typename, BBOX, args.output_dir / "troncon_hydrographique.geojson")
|
| 224 |
+
|
| 225 |
+
print()
|
| 226 |
+
print("Fetching surface_hydrographique (includes karst/Nature classification)...")
|
| 227 |
+
fetch_features(args.surface_typename, BBOX, args.output_dir / "surface_hydrographique.geojson")
|
| 228 |
+
|
| 229 |
+
if not args.skip_catchments:
|
| 230 |
+
print()
|
| 231 |
+
print("Fetching bassin_versant_topographique (catchment polygons)...")
|
| 232 |
+
print("NOTE: this typeName is unverified -- if it fails, run --check first "
|
| 233 |
+
"to find the real name, or pass --skip-catchments to skip it.")
|
| 234 |
+
fetch_features(args.catchment_typename, BBOX, args.output_dir / "bassin_versant_topographique.geojson")
|
| 235 |
+
|
| 236 |
+
print()
|
| 237 |
+
print("Done. Load with: BDTopoHydroLoader(data_path=...)")
|
| 238 |
+
print("To check for the karst-flagged reach near Grosley-sur-Risle/Ajou, use "
|
| 239 |
+
"loader.check_karst_near_point(lat, lon) -- that's the check that would "
|
| 240 |
+
"confirm or deny the bétoire location.")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
if __name__ == "__main__":
|
| 244 |
+
main()
|
scripts/data_acquisition/download_c3s_seasonal.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Downloads real seasonal forecast data from Copernicus's C3S service --
|
| 3 |
+
genuine physics-based forecasts from operational centers (ECMWF's SEAS5
|
| 4 |
+
by default), not our own statistical extrapolation from ERA5 history.
|
| 5 |
+
Same account/library (cdsapi) already used for download_era5_full.py.
|
| 6 |
+
|
| 7 |
+
CONFIRMED (via ECMWF/Copernicus documentation, not guessed): dataset
|
| 8 |
+
'seasonal-original-single-levels' for daily-resolution data, real
|
| 9 |
+
request parameters originating_centre/system/variable/year/month/
|
| 10 |
+
leadtime_hour/area/format. GRIB is the CDS default format; netCDF
|
| 11 |
+
("experimental" per ECMWF's own docs) must be requested explicitly to
|
| 12 |
+
match this project's existing xarray-based pipeline (SAFRANLoader etc.)
|
| 13 |
+
rather than adding a new GRIB-reading dependency.
|
| 14 |
+
|
| 15 |
+
NOT independently confirmed, flagged rather than assumed:
|
| 16 |
+
- The exact 'system' version number for ECMWF SEAS5 -- these get
|
| 17 |
+
revised over time by the provider. Run --check first; if it fails,
|
| 18 |
+
check the real current value at
|
| 19 |
+
https://cds.climate.copernicus.eu/datasets/seasonal-original-single-levels
|
| 20 |
+
- Whether a separate monthly-statistics dataset (rather than daily
|
| 21 |
+
with an hourly leadtime) is the better fit here -- --frequency lets
|
| 22 |
+
you try both; daily-with-leadtime_hour is what's directly confirmed
|
| 23 |
+
in the docs, so it's the default.
|
| 24 |
+
|
| 25 |
+
AREA / AXIS ORDER: the CDS 'area' parameter uses [North, West, South,
|
| 26 |
+
East] order -- confirmed different from this project's own
|
| 27 |
+
(min_lon, min_lat, max_lon, max_lat) bbox convention used everywhere
|
| 28 |
+
else (download_bdtopo_hydro.py, download_bdcavites.py). Converted
|
| 29 |
+
explicitly in build_area_param below rather than passed through
|
| 30 |
+
unchanged -- getting an axis order wrong has silently broken a request
|
| 31 |
+
in this project before (BD TOPO's WFS), so this is checked directly,
|
| 32 |
+
not assumed compatible.
|
| 33 |
+
|
| 34 |
+
Two request types, genuinely different, both needed:
|
| 35 |
+
- hindcasts (1993-2016): historical reforecasts, needed to bias-
|
| 36 |
+
correct/calibrate the real-time forecast against local climatology
|
| 37 |
+
the same way climate_climatology.py already does for ERA5.
|
| 38 |
+
- real-time forecasts (2017+): the actual current forecast to use at
|
| 39 |
+
inference time.
|
| 40 |
+
|
| 41 |
+
Usage:
|
| 42 |
+
python -m scripts.download_c3s_seasonal --check
|
| 43 |
+
python -m scripts.download_c3s_seasonal --mode hindcast --month 1
|
| 44 |
+
python -m scripts.download_c3s_seasonal --mode forecast --year 2026 --month 8
|
| 45 |
+
"""
|
| 46 |
+
import argparse
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
# Matches download_bdtopo_hydro.py's widened bbox, same study area.
|
| 50 |
+
BBOX = (-0.1, 47.7, 2.1, 49.9) # (min_lon, min_lat, max_lon, max_lat)
|
| 51 |
+
|
| 52 |
+
DATASET = "seasonal-original-single-levels"
|
| 53 |
+
DEFAULT_ORIGINATING_CENTRE = "ecmwf"
|
| 54 |
+
DEFAULT_SYSTEM = "51" # SEAS5 -- NOT independently confirmed, see module docstring
|
| 55 |
+
|
| 56 |
+
# ERA5-style variable names, matching this project's existing SAFRANLoader
|
| 57 |
+
# VAR_MAP naming convention where the CDS names line up (t2m, tp) --
|
| 58 |
+
# NOT independently confirmed identical for the seasonal dataset
|
| 59 |
+
# specifically; run --check before trusting this list.
|
| 60 |
+
DEFAULT_VARIABLES = ["2m_temperature", "total_precipitation"]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def build_area_param(bbox: tuple) -> list:
|
| 64 |
+
"""
|
| 65 |
+
CDS 'area' is [North, West, South, East] -- confirmed different
|
| 66 |
+
from this project's own (min_lon, min_lat, max_lon, max_lat)
|
| 67 |
+
convention. Converts explicitly rather than passing bbox through
|
| 68 |
+
unchanged.
|
| 69 |
+
"""
|
| 70 |
+
min_lon, min_lat, max_lon, max_lat = bbox
|
| 71 |
+
return [max_lat, min_lon, min_lat, max_lon] # [North, West, South, East]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_hindcast_request(month: int, variables: list, system: str, originating_centre: str) -> dict:
|
| 75 |
+
"""
|
| 76 |
+
One request per initialization MONTH, covering all hindcast years
|
| 77 |
+
at once -- ECMWF's own efficiency guidance (confirmed in their
|
| 78 |
+
documentation) is that this is much more efficient than looping
|
| 79 |
+
per-year, since a single month across many years reuses the same
|
| 80 |
+
underlying data partition.
|
| 81 |
+
"""
|
| 82 |
+
return {
|
| 83 |
+
"originating_centre": originating_centre,
|
| 84 |
+
"system": system,
|
| 85 |
+
"variable": variables,
|
| 86 |
+
"year": [str(y) for y in range(1993, 2017)],
|
| 87 |
+
"month": [f"{month:02d}"],
|
| 88 |
+
"day": ["01"],
|
| 89 |
+
"leadtime_hour": [str(h) for h in range(24, 24 * 186, 24)], # daily steps out to ~6 months
|
| 90 |
+
"area": build_area_param(BBOX),
|
| 91 |
+
"format": "netcdf",
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def build_forecast_request(year: int, month: int, variables: list, system: str, originating_centre: str) -> dict:
|
| 96 |
+
"""One real-time forecast initialization (a specific year+month), same variables/area/format as hindcasts."""
|
| 97 |
+
return {
|
| 98 |
+
"originating_centre": originating_centre,
|
| 99 |
+
"system": system,
|
| 100 |
+
"variable": variables,
|
| 101 |
+
"year": [str(year)],
|
| 102 |
+
"month": [f"{month:02d}"],
|
| 103 |
+
"day": ["01"],
|
| 104 |
+
"leadtime_hour": [str(h) for h in range(24, 24 * 186, 24)],
|
| 105 |
+
"area": build_area_param(BBOX),
|
| 106 |
+
"format": "netcdf",
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def check_access(originating_centre: str, system: str) -> bool:
|
| 111 |
+
"""Small real request (single variable, single hindcast month, short
|
| 112 |
+
leadtime range) to validate credentials/dataset/system/variable
|
| 113 |
+
names actually work before committing to a full download."""
|
| 114 |
+
import cdsapi
|
| 115 |
+
|
| 116 |
+
client = cdsapi.Client()
|
| 117 |
+
request = {
|
| 118 |
+
"originating_centre": originating_centre,
|
| 119 |
+
"system": system,
|
| 120 |
+
"variable": ["2m_temperature"],
|
| 121 |
+
"year": ["2015"],
|
| 122 |
+
"month": ["01"],
|
| 123 |
+
"day": ["01"],
|
| 124 |
+
"leadtime_hour": ["24", "48"],
|
| 125 |
+
"area": build_area_param(BBOX),
|
| 126 |
+
"format": "netcdf",
|
| 127 |
+
}
|
| 128 |
+
print(f"Testing {DATASET} with originating_centre={originating_centre!r}, system={system!r}...")
|
| 129 |
+
try:
|
| 130 |
+
client.retrieve(DATASET, request, "/tmp/c3s_check.nc")
|
| 131 |
+
print("OK -- request succeeded, real data downloaded to /tmp/c3s_check.nc")
|
| 132 |
+
return True
|
| 133 |
+
except Exception as e:
|
| 134 |
+
print(f"FAILED: {e}")
|
| 135 |
+
print(f"Check the real current dataset/parameter values at "
|
| 136 |
+
f"https://cds.climate.copernicus.eu/datasets/{DATASET}")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def main() -> None:
|
| 141 |
+
parser = argparse.ArgumentParser(description="Download C3S seasonal forecast data")
|
| 142 |
+
parser.add_argument("--check", action="store_true")
|
| 143 |
+
parser.add_argument("--mode", choices=["hindcast", "forecast"], default="hindcast")
|
| 144 |
+
parser.add_argument("--month", type=int, required=False, help="Initialization month (1-12)")
|
| 145 |
+
parser.add_argument("--year", type=int, required=False, help="Forecast year (--mode forecast only)")
|
| 146 |
+
parser.add_argument("--originating-centre", type=str, default=DEFAULT_ORIGINATING_CENTRE)
|
| 147 |
+
parser.add_argument("--system", type=str, default=DEFAULT_SYSTEM)
|
| 148 |
+
parser.add_argument("--variables", type=str, nargs="+", default=DEFAULT_VARIABLES)
|
| 149 |
+
parser.add_argument("--output-dir", type=Path, default=Path("datasets/c3s_seasonal"))
|
| 150 |
+
args = parser.parse_args()
|
| 151 |
+
|
| 152 |
+
if args.check:
|
| 153 |
+
check_access(args.originating_centre, args.system)
|
| 154 |
+
return
|
| 155 |
+
|
| 156 |
+
if args.month is None:
|
| 157 |
+
print("--month is required (1-12) unless using --check")
|
| 158 |
+
return
|
| 159 |
+
|
| 160 |
+
import cdsapi
|
| 161 |
+
client = cdsapi.Client()
|
| 162 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 163 |
+
|
| 164 |
+
if args.mode == "hindcast":
|
| 165 |
+
request = build_hindcast_request(args.month, args.variables, args.system, args.originating_centre)
|
| 166 |
+
out_path = args.output_dir / f"hindcast_month{args.month:02d}.nc"
|
| 167 |
+
else:
|
| 168 |
+
if args.year is None:
|
| 169 |
+
print("--year is required for --mode forecast")
|
| 170 |
+
return
|
| 171 |
+
request = build_forecast_request(args.year, args.month, args.variables, args.system, args.originating_centre)
|
| 172 |
+
out_path = args.output_dir / f"forecast_{args.year}_{args.month:02d}.nc"
|
| 173 |
+
|
| 174 |
+
print(f"Requesting {DATASET} ({args.mode}, month={args.month})...")
|
| 175 |
+
print("This can take a while -- CDS queues large seasonal forecast requests.")
|
| 176 |
+
client.retrieve(DATASET, request, str(out_path))
|
| 177 |
+
print(f"Saved to {out_path}")
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
if __name__ == "__main__":
|
| 181 |
+
main()
|
scripts/data_acquisition/download_catchment.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download catchment (drainage basin) area for all stations from Hub'Eau's
|
| 3 |
+
own hydrometrie referentiel — the same API download_hubeau.py already
|
| 4 |
+
uses, just a different endpoint.
|
| 5 |
+
|
| 6 |
+
Catchment area is NOT on the station referentiel directly; it lives on
|
| 7 |
+
the SITE referentiel (`surface_bv`, "Superficie du BV en km2"). A site
|
| 8 |
+
can have multiple stations, so this does two passes:
|
| 9 |
+
1. referentiel/stations -> get each station's code_site
|
| 10 |
+
2. referentiel/sites -> get surface_bv for those code_site values
|
| 11 |
+
3. join back onto station_code
|
| 12 |
+
|
| 13 |
+
This gives real drainage-area data usable for area-ratio discharge
|
| 14 |
+
scaling between gauges on the same river (Q_ungauged ~= Q_gauged *
|
| 15 |
+
(area_ungauged / area_gauged)) — a physically grounded alternative to
|
| 16 |
+
naive geographic proximity interpolation.
|
| 17 |
+
"""
|
| 18 |
+
import requests
|
| 19 |
+
import pandas as pd
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
BASE_URL = "https://hubeau.eaufrance.fr/api/v2/hydrometrie"
|
| 23 |
+
OUTPUT_DIR = Path("datasets")
|
| 24 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 25 |
+
|
| 26 |
+
stations_df = pd.read_csv("datasets/station_list.csv")
|
| 27 |
+
station_codes = stations_df["station_code"].tolist()
|
| 28 |
+
|
| 29 |
+
print(f"Fetching catchment area for {len(station_codes)} stations...")
|
| 30 |
+
print()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def fetch_all(endpoint: str, params: dict) -> list:
|
| 34 |
+
"""Fetch every page from a Hub'Eau referentiel endpoint (cursor pagination)."""
|
| 35 |
+
all_data = []
|
| 36 |
+
cursor = ""
|
| 37 |
+
while True:
|
| 38 |
+
query = dict(params, cursor=cursor, size=1000, format="json")
|
| 39 |
+
response = requests.get(f"{BASE_URL}/{endpoint}", params=query, timeout=30)
|
| 40 |
+
if response.status_code not in (200, 206):
|
| 41 |
+
print(f" Error {response.status_code}: {response.text[:200]}")
|
| 42 |
+
break
|
| 43 |
+
data = response.json()
|
| 44 |
+
results = data.get("data", [])
|
| 45 |
+
if not results:
|
| 46 |
+
break
|
| 47 |
+
all_data.extend(results)
|
| 48 |
+
next_url = data.get("next")
|
| 49 |
+
if not next_url:
|
| 50 |
+
break
|
| 51 |
+
if "cursor=" in next_url:
|
| 52 |
+
cursor = next_url.split("cursor=")[1].split("&")[0]
|
| 53 |
+
else:
|
| 54 |
+
break
|
| 55 |
+
return all_data
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --- Step 1: station -> code_site -----------------------------------------
|
| 59 |
+
print("=" * 80)
|
| 60 |
+
print("STEP 1: Looking up code_site for each station")
|
| 61 |
+
print("=" * 80)
|
| 62 |
+
|
| 63 |
+
station_to_site = {}
|
| 64 |
+
# code_station accepts up to 100 comma-separated codes per request
|
| 65 |
+
for i in range(0, len(station_codes), 100):
|
| 66 |
+
batch = station_codes[i:i + 100]
|
| 67 |
+
results = fetch_all("referentiel/stations", {"code_station": ",".join(batch)})
|
| 68 |
+
for r in results:
|
| 69 |
+
station_to_site[r["code_station"]] = r.get("code_site")
|
| 70 |
+
print(f" {r['code_station']} -> site {r.get('code_site')}")
|
| 71 |
+
|
| 72 |
+
missing = set(station_codes) - set(station_to_site)
|
| 73 |
+
if missing:
|
| 74 |
+
print(f"\n Warning: {len(missing)} station(s) not found in referentiel/stations: {missing}")
|
| 75 |
+
|
| 76 |
+
# --- Step 2: code_site -> surface_bv ---------------------------------------
|
| 77 |
+
print()
|
| 78 |
+
print("=" * 80)
|
| 79 |
+
print("STEP 2: Fetching surface_bv (catchment area) for each site")
|
| 80 |
+
print("=" * 80)
|
| 81 |
+
|
| 82 |
+
site_codes = sorted(set(s for s in station_to_site.values() if s))
|
| 83 |
+
site_to_area = {}
|
| 84 |
+
for i in range(0, len(site_codes), 100):
|
| 85 |
+
batch = site_codes[i:i + 100]
|
| 86 |
+
results = fetch_all("referentiel/sites", {"code_site": ",".join(batch)})
|
| 87 |
+
for r in results:
|
| 88 |
+
area = r.get("surface_bv")
|
| 89 |
+
site_to_area[r["code_site"]] = area
|
| 90 |
+
print(f" site {r['code_site']} ({r.get('libelle_site', '?')}): "
|
| 91 |
+
f"{area if area is not None else 'no data'} km²")
|
| 92 |
+
|
| 93 |
+
# --- Step 3: join back onto station_code -----------------------------------
|
| 94 |
+
rows = []
|
| 95 |
+
for code in station_codes:
|
| 96 |
+
site = station_to_site.get(code)
|
| 97 |
+
area = site_to_area.get(site) if site else None
|
| 98 |
+
rows.append({"station_code": code, "code_site": site, "catchment_area_km2": area})
|
| 99 |
+
|
| 100 |
+
result_df = pd.DataFrame(rows)
|
| 101 |
+
output_file = OUTPUT_DIR / "catchment_area.csv"
|
| 102 |
+
result_df.to_csv(output_file, index=False)
|
| 103 |
+
|
| 104 |
+
print()
|
| 105 |
+
print("=" * 80)
|
| 106 |
+
print("DONE")
|
| 107 |
+
print("=" * 80)
|
| 108 |
+
n_ok = result_df["catchment_area_km2"].notna().sum()
|
| 109 |
+
print(f"Catchment area found for {n_ok}/{len(result_df)} stations")
|
| 110 |
+
print(f"Saved to: {output_file}")
|
| 111 |
+
if n_ok < len(result_df):
|
| 112 |
+
missing_codes = result_df[result_df["catchment_area_km2"].isna()]["station_code"].tolist()
|
| 113 |
+
print(f"\nNo area found for: {missing_codes}")
|
| 114 |
+
print("(Hub'Eau doesn't publish surface_bv for every site — some are genuinely blank.)")
|
scripts/data_acquisition/download_elevation.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download elevation data for all 27 stations using Open Topo Data API."""
|
| 2 |
+
import requests
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Load stations
|
| 8 |
+
stations = pd.read_csv('datasets/station_list.csv')
|
| 9 |
+
|
| 10 |
+
print(f"Downloading elevation for {len(stations)} stations...")
|
| 11 |
+
|
| 12 |
+
elevations = []
|
| 13 |
+
|
| 14 |
+
for idx, row in stations.iterrows():
|
| 15 |
+
station_code = row['station_code']
|
| 16 |
+
lat = row['lat']
|
| 17 |
+
lon = row['lon']
|
| 18 |
+
|
| 19 |
+
# Query Open Topo Data API
|
| 20 |
+
url = f"https://api.opentopodata.org/v1/eudem25m?locations={lat},{lon}"
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
response = requests.get(url, timeout=10)
|
| 24 |
+
if response.status_code == 200:
|
| 25 |
+
data = response.json()
|
| 26 |
+
elevation = data['results'][0]['elevation']
|
| 27 |
+
elevations.append({
|
| 28 |
+
'station_code': station_code,
|
| 29 |
+
'station_name': row['station_name'],
|
| 30 |
+
'latitude': lat,
|
| 31 |
+
'longitude': lon,
|
| 32 |
+
'elevation_m': elevation,
|
| 33 |
+
'data_source': 'EU-DEM 25m (Open Topo Data)'
|
| 34 |
+
})
|
| 35 |
+
print(f"✓ {station_code}: {elevation:.1f} m")
|
| 36 |
+
else:
|
| 37 |
+
print(f"✗ {station_code}: API error {response.status_code}")
|
| 38 |
+
elevations.append({
|
| 39 |
+
'station_code': station_code,
|
| 40 |
+
'station_name': row['station_name'],
|
| 41 |
+
'latitude': lat,
|
| 42 |
+
'longitude': lon,
|
| 43 |
+
'elevation_m': None,
|
| 44 |
+
'data_source': 'Failed'
|
| 45 |
+
})
|
| 46 |
+
except Exception as e:
|
| 47 |
+
print(f"✗ {station_code}: {e}")
|
| 48 |
+
elevations.append({
|
| 49 |
+
'station_code': station_code,
|
| 50 |
+
'station_name': row['station_name'],
|
| 51 |
+
'latitude': lat,
|
| 52 |
+
'longitude': lon,
|
| 53 |
+
'elevation_m': None,
|
| 54 |
+
'data_source': 'Failed'
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
# Rate limiting
|
| 58 |
+
time.sleep(3)
|
| 59 |
+
|
| 60 |
+
# Save
|
| 61 |
+
df_elevation = pd.DataFrame(elevations)
|
| 62 |
+
output_file = Path('datasets/station_elevations.csv')
|
| 63 |
+
df_elevation.to_csv(output_file, index=False)
|
| 64 |
+
|
| 65 |
+
print(f"\n✓ Saved to: {output_file}")
|
| 66 |
+
print(f"✓ Success: {df_elevation['elevation_m'].notna().sum()}/{len(df_elevation)} stations")
|
| 67 |
+
print(f"\nElevation range: {df_elevation['elevation_m'].min():.1f} - {df_elevation['elevation_m'].max():.1f} m")
|
scripts/data_acquisition/download_era5_full.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download full ERA5 dataset (1960-2026) for all 27 stations.
|
| 3 |
+
Downloads year by year with separate requests for instantaneous vs accumulated variables.
|
| 4 |
+
|
| 5 |
+
NOTE: ERA5 separates instantaneous (temperature, wind) from accumulated
|
| 6 |
+
(precipitation, evaporation, radiation) variables. They must be requested separately.
|
| 7 |
+
"""
|
| 8 |
+
import cdsapi
|
| 9 |
+
import pandas as pd
|
| 10 |
+
import xarray as xr
|
| 11 |
+
import zipfile
|
| 12 |
+
import shutil
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# Load station coordinates
|
| 16 |
+
stations = pd.read_csv('datasets/station_list.csv')
|
| 17 |
+
lat_min = stations['lat'].min() - 0.1
|
| 18 |
+
lat_max = stations['lat'].max() + 0.1
|
| 19 |
+
lon_min = stations['lon'].min() - 0.1
|
| 20 |
+
lon_max = stations['lon'].max() + 0.1
|
| 21 |
+
|
| 22 |
+
print("="*80)
|
| 23 |
+
print("ERA5 FULL DOWNLOAD (1960-2026)")
|
| 24 |
+
print("="*80)
|
| 25 |
+
print(f"Study area: [{lat_min:.2f}, {lon_min:.2f}] to [{lat_max:.2f}, {lon_max:.2f}]")
|
| 26 |
+
print(f"Years: 1960-2026 (67 years)")
|
| 27 |
+
print(f"Output: datasets/safran/")
|
| 28 |
+
print("="*80)
|
| 29 |
+
|
| 30 |
+
# Split variables by type (instantaneous vs accumulated)
|
| 31 |
+
instantaneous_vars = [
|
| 32 |
+
'2m_temperature', # T_Q
|
| 33 |
+
'10m_u_component_of_wind', # FF_Q
|
| 34 |
+
'10m_v_component_of_wind', # FF_Q
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
accumulated_vars = [
|
| 38 |
+
'total_precipitation', # PRELIQ_Q
|
| 39 |
+
'potential_evaporation', # ETP_Q
|
| 40 |
+
'surface_solar_radiation_downwards', # DLI_Q / SSI_Q
|
| 41 |
+
'snowfall', # PRENEI_Q
|
| 42 |
+
'runoff', # RUNC_Q
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
def extract_netcdf(zip_file):
|
| 46 |
+
"""Extract NetCDF from ZIP archive."""
|
| 47 |
+
if zipfile.is_zipfile(zip_file):
|
| 48 |
+
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
|
| 49 |
+
# Find all .nc files in the zip
|
| 50 |
+
nc_files = [f for f in zip_ref.namelist() if f.endswith('.nc')]
|
| 51 |
+
extracted_files = []
|
| 52 |
+
for nc_file in nc_files:
|
| 53 |
+
temp_file = zip_file.parent / f'temp_{nc_file}'
|
| 54 |
+
with zip_ref.open(nc_file) as source:
|
| 55 |
+
with open(temp_file, 'wb') as target:
|
| 56 |
+
shutil.copyfileobj(source, target)
|
| 57 |
+
extracted_files.append(temp_file)
|
| 58 |
+
zip_file.unlink()
|
| 59 |
+
return extracted_files
|
| 60 |
+
return [zip_file]
|
| 61 |
+
|
| 62 |
+
def merge_datasets(instant_file, accum_file, output_file):
|
| 63 |
+
"""Merge instantaneous and accumulated variable datasets."""
|
| 64 |
+
ds_instant = xr.open_dataset(instant_file)
|
| 65 |
+
ds_accum = xr.open_dataset(accum_file)
|
| 66 |
+
|
| 67 |
+
# Merge datasets
|
| 68 |
+
ds_merged = xr.merge([ds_instant, ds_accum])
|
| 69 |
+
ds_merged.to_netcdf(output_file)
|
| 70 |
+
|
| 71 |
+
ds_instant.close()
|
| 72 |
+
ds_accum.close()
|
| 73 |
+
instant_file.unlink()
|
| 74 |
+
accum_file.unlink()
|
| 75 |
+
|
| 76 |
+
c = cdsapi.Client()
|
| 77 |
+
output_dir = Path('datasets/safran')
|
| 78 |
+
output_dir.mkdir(exist_ok=True)
|
| 79 |
+
|
| 80 |
+
# Download year by year (1960-2026)
|
| 81 |
+
years = list(range(1960, 2027)) # 1960 to 2026 inclusive
|
| 82 |
+
failed_years = []
|
| 83 |
+
|
| 84 |
+
for year in years:
|
| 85 |
+
output_file = output_dir / f'era5_{year}.nc'
|
| 86 |
+
|
| 87 |
+
# Skip if already exists and contains all 8 variables
|
| 88 |
+
if output_file.exists():
|
| 89 |
+
try:
|
| 90 |
+
ds = xr.open_dataset(output_file)
|
| 91 |
+
if len(ds.data_vars) >= 8:
|
| 92 |
+
print(f"✓ {year} - Already downloaded with all variables, skipping")
|
| 93 |
+
ds.close()
|
| 94 |
+
continue
|
| 95 |
+
else:
|
| 96 |
+
print(f"⚠ {year} - Incomplete ({len(ds.data_vars)} vars), re-downloading")
|
| 97 |
+
ds.close()
|
| 98 |
+
output_file.unlink()
|
| 99 |
+
except:
|
| 100 |
+
output_file.unlink()
|
| 101 |
+
|
| 102 |
+
print(f"\nDownloading {year}...")
|
| 103 |
+
|
| 104 |
+
instant_file = output_dir / f'era5_{year}_instant.nc'
|
| 105 |
+
accum_file = output_dir / f'era5_{year}_accum.nc'
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
# Download instantaneous variables
|
| 109 |
+
print(f" → Instantaneous variables...")
|
| 110 |
+
c.retrieve(
|
| 111 |
+
'reanalysis-era5-single-levels',
|
| 112 |
+
{
|
| 113 |
+
'product_type': 'reanalysis',
|
| 114 |
+
'format': 'netcdf',
|
| 115 |
+
'variable': instantaneous_vars,
|
| 116 |
+
'year': str(year),
|
| 117 |
+
'month': [f'{m:02d}' for m in range(1, 13)],
|
| 118 |
+
'day': [f'{d:02d}' for d in range(1, 32)],
|
| 119 |
+
'time': '12:00',
|
| 120 |
+
'area': [lat_max, lon_min, lat_min, lon_max],
|
| 121 |
+
},
|
| 122 |
+
str(instant_file)
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Extract if ZIP
|
| 126 |
+
extracted = extract_netcdf(instant_file)
|
| 127 |
+
if len(extracted) > 0:
|
| 128 |
+
instant_file = extracted[0]
|
| 129 |
+
|
| 130 |
+
# Download accumulated variables
|
| 131 |
+
print(f" → Accumulated variables...")
|
| 132 |
+
c.retrieve(
|
| 133 |
+
'reanalysis-era5-single-levels',
|
| 134 |
+
{
|
| 135 |
+
'product_type': 'reanalysis',
|
| 136 |
+
'format': 'netcdf',
|
| 137 |
+
'variable': accumulated_vars,
|
| 138 |
+
'year': str(year),
|
| 139 |
+
'month': [f'{m:02d}' for m in range(1, 13)],
|
| 140 |
+
'day': [f'{d:02d}' for d in range(1, 32)],
|
| 141 |
+
'time': '12:00',
|
| 142 |
+
'area': [lat_max, lon_min, lat_min, lon_max],
|
| 143 |
+
},
|
| 144 |
+
str(accum_file)
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# Extract if ZIP
|
| 148 |
+
extracted = extract_netcdf(accum_file)
|
| 149 |
+
if len(extracted) > 0:
|
| 150 |
+
accum_file = extracted[0]
|
| 151 |
+
|
| 152 |
+
# Merge both datasets
|
| 153 |
+
print(f" → Merging datasets...")
|
| 154 |
+
merge_datasets(instant_file, accum_file, output_file)
|
| 155 |
+
|
| 156 |
+
size_kb = output_file.stat().st_size / 1024
|
| 157 |
+
print(f"✓ {year} - Downloaded ({size_kb:.1f} KB)")
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
print(f"✗ {year} - Failed: {e}")
|
| 161 |
+
failed_years.append(year)
|
| 162 |
+
# Cleanup partial files
|
| 163 |
+
for f in [instant_file, accum_file]:
|
| 164 |
+
if f.exists():
|
| 165 |
+
f.unlink()
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
print("\n" + "="*80)
|
| 169 |
+
print("DOWNLOAD COMPLETE")
|
| 170 |
+
print("="*80)
|
| 171 |
+
print(f"Successful: {len(years) - len(failed_years)}/{len(years)} years")
|
| 172 |
+
|
| 173 |
+
if failed_years:
|
| 174 |
+
print(f"\nFailed years: {failed_years}")
|
| 175 |
+
print("You can re-run this script to retry failed downloads")
|
| 176 |
+
else:
|
| 177 |
+
print("\n✓ All years downloaded successfully!")
|
| 178 |
+
|
| 179 |
+
print(f"\nFiles saved to: {output_dir}")
|
| 180 |
+
print("Total files:", len(list(output_dir.glob('era5_*.nc'))))
|
scripts/data_acquisition/download_era5_sample.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download ERA5 sample (Jan 2020) to test setup."""
|
| 2 |
+
import cdsapi
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
# Load station coordinates for bounding box
|
| 7 |
+
stations = pd.read_csv('datasets/station_list.csv')
|
| 8 |
+
lat_min = stations['lat'].min() - 0.1
|
| 9 |
+
lat_max = stations['lat'].max() + 0.1
|
| 10 |
+
lon_min = stations['lon'].min() - 0.1
|
| 11 |
+
lon_max = stations['lon'].max() + 0.1
|
| 12 |
+
|
| 13 |
+
print(f"Study area: [{lat_min:.2f}, {lon_min:.2f}] to [{lat_max:.2f}, {lon_max:.2f}]")
|
| 14 |
+
print("Downloading ERA5 sample: January 2020...")
|
| 15 |
+
|
| 16 |
+
c = cdsapi.Client()
|
| 17 |
+
|
| 18 |
+
output_file = Path('datasets/safran/era5_2020_jan.nc')
|
| 19 |
+
|
| 20 |
+
# Download sample month
|
| 21 |
+
c.retrieve(
|
| 22 |
+
'reanalysis-era5-single-levels',
|
| 23 |
+
{
|
| 24 |
+
'product_type': 'reanalysis',
|
| 25 |
+
'format': 'netcdf',
|
| 26 |
+
'variable': [
|
| 27 |
+
'2m_temperature',
|
| 28 |
+
'total_precipitation',
|
| 29 |
+
'potential_evaporation',
|
| 30 |
+
'surface_solar_radiation_downwards',
|
| 31 |
+
'10m_u_component_of_wind',
|
| 32 |
+
'10m_v_component_of_wind',
|
| 33 |
+
],
|
| 34 |
+
'year': '2020',
|
| 35 |
+
'month': '01',
|
| 36 |
+
'day': [f'{d:02d}' for d in range(1, 32)],
|
| 37 |
+
'time': '12:00',
|
| 38 |
+
'area': [lat_max, lon_min, lat_min, lon_max],
|
| 39 |
+
},
|
| 40 |
+
str(output_file)
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
print(f"✓ Downloaded: {output_file}")
|
| 44 |
+
print(f"✓ Size: {output_file.stat().st_size / 1024 / 1024:.1f} MB")
|
scripts/data_acquisition/download_hubeau.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download hydrometric data from Hub'Eau API v2.
|
| 3 |
+
Downloads QmJ (daily discharge), HIJ (daily water level) for all 27 stations.
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
# Configuration
|
| 12 |
+
OUTPUT_DIR = Path("datasets/hydrometric")
|
| 13 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 14 |
+
|
| 15 |
+
BASE_URL = "https://hubeau.eaufrance.fr/api/v2/hydrometrie"
|
| 16 |
+
|
| 17 |
+
# Load stations
|
| 18 |
+
stations_df = pd.read_csv("datasets/station_list.csv")
|
| 19 |
+
station_codes = stations_df["station_code"].tolist()
|
| 20 |
+
|
| 21 |
+
print(f"Downloading hydrometric data for {len(station_codes)} stations...")
|
| 22 |
+
print(f"Output: {OUTPUT_DIR}")
|
| 23 |
+
print()
|
| 24 |
+
|
| 25 |
+
def download_observations(station_code, grandeur_hydro="Q", max_results=100000):
|
| 26 |
+
"""
|
| 27 |
+
Download observations for a station.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
station_code: Station code (e.g., H602021010)
|
| 31 |
+
grandeur_hydro: H (water level) or Q (discharge)
|
| 32 |
+
max_results: Maximum results to fetch
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
DataFrame with observations
|
| 36 |
+
"""
|
| 37 |
+
url = f"{BASE_URL}/obs_elab"
|
| 38 |
+
|
| 39 |
+
all_data = []
|
| 40 |
+
cursor = ""
|
| 41 |
+
page = 1
|
| 42 |
+
|
| 43 |
+
while len(all_data) < max_results:
|
| 44 |
+
params = {
|
| 45 |
+
"code_entite": station_code,
|
| 46 |
+
"grandeur_hydro": grandeur_hydro,
|
| 47 |
+
"size": 20000, # Max per request
|
| 48 |
+
"format": "json",
|
| 49 |
+
"cursor": cursor
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
response = requests.get(url, params=params, timeout=30)
|
| 54 |
+
|
| 55 |
+
if response.status_code == 200:
|
| 56 |
+
data = response.json()
|
| 57 |
+
results = data.get("data", [])
|
| 58 |
+
|
| 59 |
+
if not results:
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
all_data.extend(results)
|
| 63 |
+
|
| 64 |
+
# Check for next page
|
| 65 |
+
next_url = data.get("next")
|
| 66 |
+
if not next_url or len(results) < 20000:
|
| 67 |
+
break
|
| 68 |
+
|
| 69 |
+
# Extract cursor from next URL
|
| 70 |
+
if "cursor=" in next_url:
|
| 71 |
+
cursor = next_url.split("cursor=")[1].split("&")[0]
|
| 72 |
+
else:
|
| 73 |
+
break
|
| 74 |
+
|
| 75 |
+
page += 1
|
| 76 |
+
time.sleep(0.1) # Rate limiting
|
| 77 |
+
|
| 78 |
+
else:
|
| 79 |
+
print(f" Error {response.status_code}")
|
| 80 |
+
break
|
| 81 |
+
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f" Exception: {e}")
|
| 84 |
+
break
|
| 85 |
+
|
| 86 |
+
if all_data:
|
| 87 |
+
return pd.DataFrame(all_data)
|
| 88 |
+
else:
|
| 89 |
+
return pd.DataFrame()
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# Download discharge (Q) for all stations
|
| 93 |
+
print("="*80)
|
| 94 |
+
print("DOWNLOADING DISCHARGE (Q)")
|
| 95 |
+
print("="*80)
|
| 96 |
+
|
| 97 |
+
discharge_data = []
|
| 98 |
+
|
| 99 |
+
for station_code in tqdm(station_codes, desc="Discharge"):
|
| 100 |
+
df = download_observations(station_code, grandeur_hydro="Q")
|
| 101 |
+
|
| 102 |
+
if not df.empty:
|
| 103 |
+
df["station_code"] = station_code
|
| 104 |
+
discharge_data.append(df)
|
| 105 |
+
tqdm.write(f"✓ {station_code}: {len(df)} observations")
|
| 106 |
+
else:
|
| 107 |
+
tqdm.write(f"✗ {station_code}: No data")
|
| 108 |
+
|
| 109 |
+
time.sleep(0.2)
|
| 110 |
+
|
| 111 |
+
if discharge_data:
|
| 112 |
+
discharge_df = pd.concat(discharge_data, ignore_index=True)
|
| 113 |
+
|
| 114 |
+
# Save
|
| 115 |
+
discharge_df.to_csv(OUTPUT_DIR / "discharge_observations.csv", index=False)
|
| 116 |
+
discharge_df.to_json(OUTPUT_DIR / "discharge_observations.json", orient="records", indent=2)
|
| 117 |
+
|
| 118 |
+
print(f"\n✓ Discharge: {len(discharge_df)} total observations")
|
| 119 |
+
print(f" Stations: {discharge_df['station_code'].nunique()}")
|
| 120 |
+
print(f" Date range: {discharge_df['date_obs_elab'].min()} to {discharge_df['date_obs_elab'].max()}")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# Download water level (H) for all stations
|
| 124 |
+
print("\n" + "="*80)
|
| 125 |
+
print("DOWNLOADING WATER LEVEL (H)")
|
| 126 |
+
print("="*80)
|
| 127 |
+
|
| 128 |
+
waterlevel_data = []
|
| 129 |
+
|
| 130 |
+
for station_code in tqdm(station_codes, desc="Water Level"):
|
| 131 |
+
df = download_observations(station_code, grandeur_hydro="H")
|
| 132 |
+
|
| 133 |
+
if not df.empty:
|
| 134 |
+
df["station_code"] = station_code
|
| 135 |
+
waterlevel_data.append(df)
|
| 136 |
+
tqdm.write(f"✓ {station_code}: {len(df)} observations")
|
| 137 |
+
else:
|
| 138 |
+
tqdm.write(f"✗ {station_code}: No data")
|
| 139 |
+
|
| 140 |
+
time.sleep(0.2)
|
| 141 |
+
|
| 142 |
+
if waterlevel_data:
|
| 143 |
+
waterlevel_df = pd.concat(waterlevel_data, ignore_index=True)
|
| 144 |
+
|
| 145 |
+
# Save
|
| 146 |
+
waterlevel_df.to_csv(OUTPUT_DIR / "waterlevel_observations.csv", index=False)
|
| 147 |
+
waterlevel_df.to_json(OUTPUT_DIR / "waterlevel_observations.json", orient="records", indent=2)
|
| 148 |
+
|
| 149 |
+
print(f"\n✓ Water level: {len(waterlevel_df)} total observations")
|
| 150 |
+
print(f" Stations: {waterlevel_df['station_code'].nunique()}")
|
| 151 |
+
print(f" Date range: {waterlevel_df['date_obs_elab'].min()} to {waterlevel_df['date_obs_elab'].max()}")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# Summary
|
| 155 |
+
print("\n" + "="*80)
|
| 156 |
+
print("DOWNLOAD COMPLETE")
|
| 157 |
+
print("="*80)
|
| 158 |
+
print(f"Files saved to: {OUTPUT_DIR}")
|
| 159 |
+
print(f"\nFiles created:")
|
| 160 |
+
for f in OUTPUT_DIR.glob("*"):
|
| 161 |
+
size_mb = f.stat().st_size / 1024 / 1024
|
| 162 |
+
print(f" - {f.name} ({size_mb:.1f} MB)")
|
scripts/data_acquisition/extract_era5.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Extract NetCDF files from ERA5 ZIP archives.
|
| 3 |
+
CDS API downloads NetCDF data as compressed ZIP files.
|
| 4 |
+
"""
|
| 5 |
+
import zipfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import shutil
|
| 8 |
+
|
| 9 |
+
safran_dir = Path('datasets/safran')
|
| 10 |
+
|
| 11 |
+
print("=" * 80)
|
| 12 |
+
print("EXTRACTING ERA5 NETCDF FILES FROM ZIP ARCHIVES")
|
| 13 |
+
print("=" * 80)
|
| 14 |
+
|
| 15 |
+
# Find all .nc files that are actually ZIP archives
|
| 16 |
+
nc_files = list(safran_dir.glob('era5_*.nc'))
|
| 17 |
+
extracted = 0
|
| 18 |
+
skipped = 0
|
| 19 |
+
|
| 20 |
+
for nc_file in sorted(nc_files):
|
| 21 |
+
# Check if it's a ZIP file
|
| 22 |
+
try:
|
| 23 |
+
with zipfile.ZipFile(nc_file, 'r') as zip_ref:
|
| 24 |
+
# It's a ZIP file - extract it
|
| 25 |
+
zip_contents = zip_ref.namelist()
|
| 26 |
+
|
| 27 |
+
# Find the actual NetCDF file inside (usually data.nc or similar)
|
| 28 |
+
actual_nc = [f for f in zip_contents if f.endswith('.nc')][0]
|
| 29 |
+
|
| 30 |
+
# Extract to temporary location
|
| 31 |
+
temp_file = safran_dir / f'temp_{nc_file.name}'
|
| 32 |
+
with zip_ref.open(actual_nc) as source:
|
| 33 |
+
with open(temp_file, 'wb') as target:
|
| 34 |
+
shutil.copyfileobj(source, target)
|
| 35 |
+
|
| 36 |
+
# Replace the ZIP with the extracted NetCDF
|
| 37 |
+
nc_file.unlink()
|
| 38 |
+
temp_file.rename(nc_file)
|
| 39 |
+
|
| 40 |
+
size_kb = nc_file.stat().st_size / 1024
|
| 41 |
+
print(f"✓ Extracted {nc_file.name} ({size_kb:.1f} KB)")
|
| 42 |
+
extracted += 1
|
| 43 |
+
|
| 44 |
+
except zipfile.BadZipFile:
|
| 45 |
+
# Not a ZIP file - it's already a proper NetCDF, skip
|
| 46 |
+
size_kb = nc_file.stat().st_size / 1024
|
| 47 |
+
print(f" {nc_file.name} - Already extracted ({size_kb:.1f} KB)")
|
| 48 |
+
skipped += 1
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"✗ Failed to extract {nc_file.name}: {e}")
|
| 51 |
+
|
| 52 |
+
print("\n" + "=" * 80)
|
| 53 |
+
print("EXTRACTION COMPLETE")
|
| 54 |
+
print("=" * 80)
|
| 55 |
+
print(f"Extracted: {extracted} files")
|
| 56 |
+
print(f"Already extracted: {skipped} files")
|
| 57 |
+
print(f"Total NetCDF files: {len(nc_files)}")
|
scripts/data_acquisition/fetch_ecmwf_forecast.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fetches real, physics-based short-to-medium-range forecast data from
|
| 3 |
+
Open-Meteo's dedicated ECMWF endpoint -- built specifically for genuine
|
| 4 |
+
flood early-warning lead time, which (per the recent design discussion)
|
| 5 |
+
structurally requires a real precipitation forecast, not just discharge
|
| 6 |
+
history extrapolated forward.
|
| 7 |
+
|
| 8 |
+
CONFIRMED real API details (not guessed): base endpoint
|
| 9 |
+
https://api.open-meteo.com/v1/ecmwf, HTTP GET, no authentication, CC BY
|
| 10 |
+
4.0 licence, up to 10 real forecast days. Also confirmed to exist (not
|
| 11 |
+
yet used here): an archived-individual-model-run-by-init-time feature
|
| 12 |
+
explicitly described by Open-Meteo as built for bias-correction
|
| 13 |
+
pipelines without look-ahead leakage -- exactly what a later step would
|
| 14 |
+
need to calibrate this forecast against our own real climatology (see
|
| 15 |
+
climate_climatology.py). The exact endpoint for that archive wasn't
|
| 16 |
+
directly confirmed in this pass -- flagged for a follow-up check, not
|
| 17 |
+
guessed at here.
|
| 18 |
+
|
| 19 |
+
Usage:
|
| 20 |
+
python -m scripts.fetch_ecmwf_forecast --check
|
| 21 |
+
python -m scripts.fetch_ecmwf_forecast --stations-path datasets/station_elevations.csv
|
| 22 |
+
"""
|
| 23 |
+
import argparse
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import pandas as pd
|
| 27 |
+
import requests
|
| 28 |
+
|
| 29 |
+
BASE_URL = "https://api.open-meteo.com/v1/ecmwf"
|
| 30 |
+
|
| 31 |
+
# Daily aggregates, matching this project's existing daily-resolution
|
| 32 |
+
# pipeline convention (ERA5 climatology etc.) rather than pulling hourly
|
| 33 |
+
# data that would need its own separate aggregation step.
|
| 34 |
+
DAILY_VARIABLES = [
|
| 35 |
+
"precipitation_sum", "temperature_2m_max", "temperature_2m_min",
|
| 36 |
+
"wind_speed_10m_max", "shortwave_radiation_sum", "et0_fao_evapotranspiration",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def check_access() -> bool:
|
| 41 |
+
"""Single real request against a real coordinate in the study area
|
| 42 |
+
(matches H602021010's real location) to confirm the endpoint and
|
| 43 |
+
variable names actually work before looping over every station."""
|
| 44 |
+
params = {
|
| 45 |
+
"latitude": 48.748705218, "longitude": 0.58156195,
|
| 46 |
+
"daily": ",".join(DAILY_VARIABLES), "forecast_days": 5,
|
| 47 |
+
"timezone": "Europe/Paris",
|
| 48 |
+
}
|
| 49 |
+
print(f"Testing {BASE_URL} with a real study-area coordinate...")
|
| 50 |
+
try:
|
| 51 |
+
resp = requests.get(BASE_URL, params=params, timeout=30)
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
data = resp.json()
|
| 54 |
+
except Exception as e:
|
| 55 |
+
print(f"FAILED: {e}")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
if "daily" not in data:
|
| 59 |
+
print(f"FAILED: response has no 'daily' key. Full response: {data}")
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
print("OK -- real forecast returned:")
|
| 63 |
+
for var in DAILY_VARIABLES:
|
| 64 |
+
if var in data["daily"]:
|
| 65 |
+
print(f" {var}: {data['daily'][var][:3]}... ({len(data['daily'][var])} real day(s))")
|
| 66 |
+
else:
|
| 67 |
+
print(f" WARNING: '{var}' not found in response -- variable name may be wrong. "
|
| 68 |
+
f"Real keys returned: {list(data['daily'].keys())}")
|
| 69 |
+
return True
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def fetch_forecast(lat: float, lon: float, forecast_days: int = 10, max_retries: int = 3) -> pd.DataFrame:
|
| 73 |
+
"""
|
| 74 |
+
Retries on read timeout -- confirmed as a real, not hypothetical,
|
| 75 |
+
intermittent failure mode against the live API: 2 of 27 real
|
| 76 |
+
stations hit a 30s read timeout on an otherwise-clean run, while
|
| 77 |
+
the other 25 succeeded immediately. A short exponential backoff
|
| 78 |
+
handles this automatically rather than requiring a manual re-run
|
| 79 |
+
for whichever stations happen to be unlucky that run.
|
| 80 |
+
"""
|
| 81 |
+
import time
|
| 82 |
+
|
| 83 |
+
params = {
|
| 84 |
+
"latitude": lat, "longitude": lon,
|
| 85 |
+
"daily": ",".join(DAILY_VARIABLES), "forecast_days": forecast_days,
|
| 86 |
+
"timezone": "Europe/Paris",
|
| 87 |
+
}
|
| 88 |
+
last_error = None
|
| 89 |
+
for attempt in range(max_retries):
|
| 90 |
+
try:
|
| 91 |
+
resp = requests.get(BASE_URL, params=params, timeout=30)
|
| 92 |
+
resp.raise_for_status()
|
| 93 |
+
data = resp.json()
|
| 94 |
+
break
|
| 95 |
+
except requests.exceptions.RequestException as e:
|
| 96 |
+
last_error = e
|
| 97 |
+
if attempt < max_retries - 1:
|
| 98 |
+
wait = 2 ** attempt # 1s, 2s, 4s
|
| 99 |
+
print(f" attempt {attempt + 1}/{max_retries} failed ({e}), retrying in {wait}s...")
|
| 100 |
+
time.sleep(wait)
|
| 101 |
+
else:
|
| 102 |
+
raise last_error
|
| 103 |
+
|
| 104 |
+
if "daily" not in data:
|
| 105 |
+
raise ValueError(f"No 'daily' data in response for ({lat}, {lon}): {data}")
|
| 106 |
+
|
| 107 |
+
df = pd.DataFrame(data["daily"])
|
| 108 |
+
df["latitude"] = lat
|
| 109 |
+
df["longitude"] = lon
|
| 110 |
+
return df
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def main() -> None:
|
| 114 |
+
parser = argparse.ArgumentParser(description="Fetch real ECMWF forecast data from Open-Meteo")
|
| 115 |
+
parser.add_argument("--check", action="store_true")
|
| 116 |
+
parser.add_argument("--stations-path", type=Path, default=Path("datasets/station_elevations.csv"))
|
| 117 |
+
parser.add_argument("--forecast-days", type=int, default=10)
|
| 118 |
+
parser.add_argument("--output", type=Path, default=Path("datasets/ecmwf_forecast.csv"))
|
| 119 |
+
args = parser.parse_args()
|
| 120 |
+
|
| 121 |
+
if args.check:
|
| 122 |
+
check_access()
|
| 123 |
+
return
|
| 124 |
+
|
| 125 |
+
stations = pd.read_csv(args.stations_path)
|
| 126 |
+
lat_col = "latitude" if "latitude" in stations.columns else "lat"
|
| 127 |
+
lon_col = "longitude" if "longitude" in stations.columns else "lon"
|
| 128 |
+
|
| 129 |
+
all_dfs = []
|
| 130 |
+
for _, row in stations.iterrows():
|
| 131 |
+
code = row.get("station_code", f"{row[lat_col]:.3f}_{row[lon_col]:.3f}")
|
| 132 |
+
print(f"Fetching real forecast for {code}...")
|
| 133 |
+
try:
|
| 134 |
+
df = fetch_forecast(row[lat_col], row[lon_col], args.forecast_days)
|
| 135 |
+
df["station_code"] = code
|
| 136 |
+
all_dfs.append(df)
|
| 137 |
+
except Exception as e:
|
| 138 |
+
print(f" FAILED for {code}: {e}")
|
| 139 |
+
|
| 140 |
+
if not all_dfs:
|
| 141 |
+
print("No real forecast data fetched at all -- check connectivity, or run --check first.")
|
| 142 |
+
return
|
| 143 |
+
|
| 144 |
+
combined = pd.concat(all_dfs, ignore_index=True)
|
| 145 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 146 |
+
combined.to_csv(args.output, index=False)
|
| 147 |
+
print(f"\nSaved real forecast data for {combined['station_code'].nunique()} station(s), "
|
| 148 |
+
f"{args.forecast_days} real day(s) each, to {args.output}")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
main()
|
scripts/data_acquisition/fetch_idpr_brgm.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fetches fresh IDPR (Indice de Développement et de Persistance des
|
| 3 |
+
Réseaux) values directly from BRGM's live geoservice, replacing/
|
| 4 |
+
supplementing the manually-uploaded idpr.csv.
|
| 5 |
+
|
| 6 |
+
CONFIRMED (fetched GetCapabilities directly): endpoint
|
| 7 |
+
http://geoservices.brgm.fr/geologie, WMS 1.3.0, live and responding.
|
| 8 |
+
IDPR is raster/grid data at five resolutions (IDPR_50M down to
|
| 9 |
+
IDPR_5000M) -- not a vector feature type, so WFS GetFeature (the
|
| 10 |
+
pattern used everywhere else in this project) doesn't apply. The
|
| 11 |
+
correct WMS operation for extracting a value at a specific point from a
|
| 12 |
+
raster layer is GetFeatureInfo: request a small map image centered on
|
| 13 |
+
the point, then ask "what's the value at this pixel."
|
| 14 |
+
|
| 15 |
+
This queries ONE point at a time (GetFeatureInfo has no bulk/vectorized
|
| 16 |
+
equivalent the way BD TOPO's WFS BBOX queries did) -- scoped to the 27
|
| 17 |
+
real gauge stations, matching what "redownload IDPR" means (fresh
|
| 18 |
+
values at known station points), not the full ~4,500-node reach graph
|
| 19 |
+
(which would mean thousands of individual HTTP requests -- a different,
|
| 20 |
+
much larger undertaking than this).
|
| 21 |
+
|
| 22 |
+
Usage:
|
| 23 |
+
python -m scripts.fetch_idpr_brgm --check
|
| 24 |
+
python -m scripts.fetch_idpr_brgm --stations datasets/station_elevations.csv
|
| 25 |
+
"""
|
| 26 |
+
import argparse
|
| 27 |
+
import re
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import pandas as pd
|
| 31 |
+
import requests
|
| 32 |
+
|
| 33 |
+
WMS_URL = "http://geoservices.brgm.fr/geologie"
|
| 34 |
+
LAYER = "IDPR_50M" # highest resolution; use --layer to try a coarser one if this fails
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_feature_info_at_point(lat: float, lon: float, layer: str = LAYER,
|
| 38 |
+
half_extent_deg: float = 0.001) -> "float | None":
|
| 39 |
+
"""
|
| 40 |
+
Query IDPR value at a single point via WMS GetFeatureInfo: request a
|
| 41 |
+
tiny 3x3 pixel map centered on (lat, lon), then ask for the value at
|
| 42 |
+
the center pixel.
|
| 43 |
+
"""
|
| 44 |
+
minx, miny = lon - half_extent_deg, lat - half_extent_deg
|
| 45 |
+
maxx, maxy = lon + half_extent_deg, lat + half_extent_deg
|
| 46 |
+
params = {
|
| 47 |
+
"SERVICE": "WMS", "VERSION": "1.1.1", "REQUEST": "GetFeatureInfo",
|
| 48 |
+
"LAYERS": layer, "QUERY_LAYERS": layer, "STYLES": "",
|
| 49 |
+
"BBOX": f"{minx},{miny},{maxx},{maxy}", "SRS": "EPSG:4326",
|
| 50 |
+
"WIDTH": 3, "HEIGHT": 3, "X": 1, "Y": 1,
|
| 51 |
+
"INFO_FORMAT": "text/plain",
|
| 52 |
+
}
|
| 53 |
+
try:
|
| 54 |
+
resp = requests.get(WMS_URL, params=params, timeout=30)
|
| 55 |
+
except requests.RequestException as e:
|
| 56 |
+
print(f" request failed: {e}")
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
if resp.status_code != 200:
|
| 60 |
+
print(f" HTTP {resp.status_code}")
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
return parse_getfeatureinfo_value(resp.text)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def parse_getfeatureinfo_value(text: str) -> "float | None":
|
| 67 |
+
"""
|
| 68 |
+
MapServer's text/plain GetFeatureInfo output is typically a small
|
| 69 |
+
block like:
|
| 70 |
+
Layer 'IDPR_50M'
|
| 71 |
+
Feature 0
|
| 72 |
+
value = '885'
|
| 73 |
+
Parsing defensively: find any line with 'value' (case-insensitive)
|
| 74 |
+
and extract the first number on it, rather than assuming an exact
|
| 75 |
+
format -- MapServer's plain-text output format has enough real
|
| 76 |
+
variation across deployments that a strict parser is more likely to
|
| 77 |
+
silently return nothing than a permissive one is to return a wrong
|
| 78 |
+
number.
|
| 79 |
+
"""
|
| 80 |
+
for line in text.splitlines():
|
| 81 |
+
if "value" in line.lower():
|
| 82 |
+
match = re.search(r"[-+]?\d*\.?\d+", line)
|
| 83 |
+
if match:
|
| 84 |
+
return float(match.group())
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def check_service() -> bool:
|
| 89 |
+
# A real point known to be in mainland France, well within IDPR coverage
|
| 90 |
+
test_lat, test_lon = 49.03, 0.79
|
| 91 |
+
print(f"Testing {LAYER} at ({test_lat}, {test_lon})...")
|
| 92 |
+
value = get_feature_info_at_point(test_lat, test_lon)
|
| 93 |
+
if value is not None:
|
| 94 |
+
print(f" OK: value = {value}")
|
| 95 |
+
return True
|
| 96 |
+
print(" FAILED: no value returned. Try --layer IDPR_100M or IDPR_500M "
|
| 97 |
+
"(coarser resolutions), or check GetCapabilities directly:")
|
| 98 |
+
print(f" {WMS_URL}?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetCapabilities")
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def fetch_for_stations(stations_path: Path, layer: str, output_path: Path) -> None:
|
| 103 |
+
stations = pd.read_csv(stations_path)
|
| 104 |
+
lat_col = "latitude" if "latitude" in stations.columns else "lat"
|
| 105 |
+
lon_col = "longitude" if "longitude" in stations.columns else "lon"
|
| 106 |
+
|
| 107 |
+
rows = []
|
| 108 |
+
for _, row in stations.iterrows():
|
| 109 |
+
code = row["station_code"]
|
| 110 |
+
value = get_feature_info_at_point(row[lat_col], row[lon_col], layer=layer)
|
| 111 |
+
print(f" {code}: {value}")
|
| 112 |
+
rows.append({"station_code": code, "idpr_value_brgm_live": value,
|
| 113 |
+
"latitude": row[lat_col], "longitude": row[lon_col]})
|
| 114 |
+
|
| 115 |
+
out = pd.DataFrame(rows)
|
| 116 |
+
n_ok = out["idpr_value_brgm_live"].notna().sum()
|
| 117 |
+
print()
|
| 118 |
+
print(f"{n_ok}/{len(out)} stations got a real value")
|
| 119 |
+
out.to_csv(output_path, index=False)
|
| 120 |
+
print(f"Saved to {output_path}")
|
| 121 |
+
if n_ok < len(out):
|
| 122 |
+
print("Some stations got no value -- this could mean they're just outside "
|
| 123 |
+
"IDPR's coverage, or the layer/resolution needs adjusting. Compare "
|
| 124 |
+
"against the existing idpr.csv for those specific stations before "
|
| 125 |
+
"assuming the live fetch is wrong.")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def main() -> None:
|
| 129 |
+
parser = argparse.ArgumentParser(description="Fetch fresh IDPR values from BRGM's live geoservice")
|
| 130 |
+
parser.add_argument("--check", action="store_true")
|
| 131 |
+
parser.add_argument("--stations", type=Path, default=Path("datasets/station_elevations.csv"))
|
| 132 |
+
parser.add_argument("--layer", type=str, default=LAYER)
|
| 133 |
+
parser.add_argument("--output", type=Path, default=Path("datasets/idpr_brgm_live.csv"))
|
| 134 |
+
args = parser.parse_args()
|
| 135 |
+
|
| 136 |
+
if args.check:
|
| 137 |
+
check_service()
|
| 138 |
+
return
|
| 139 |
+
|
| 140 |
+
fetch_for_stations(args.stations, args.layer, args.output)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
scripts/data_acquisition/fetch_landcover.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Samples ESA WorldCover landcover classification and NDVI at station
|
| 3 |
+
points, from the public AWS S3 Cloud-Optimized GeoTIFF tiles -- not the
|
| 4 |
+
Terrascope WMS.
|
| 5 |
+
|
| 6 |
+
WHY NOT WMS: a source dated July 2026 reports Terrascope's WMS actively
|
| 7 |
+
resets connections from non-browser HTTP clients (TLS fingerprinting,
|
| 8 |
+
confirmed across curl/wget/Node fetch with multiple User-Agents) --
|
| 9 |
+
this is not a coding problem to work around, it's the service
|
| 10 |
+
declining non-browser clients. Separately, that WMS was scheduled for
|
| 11 |
+
full phase-out in January 2026, already past. Using ESA's own
|
| 12 |
+
recommended alternative instead: direct S3 access, "avoids unzipping
|
| 13 |
+
steps" per ESA's own documentation, genuinely public, no auth/signing.
|
| 14 |
+
|
| 15 |
+
TILE: ESA WorldCover ships as 3x3 degree COG tiles named by their
|
| 16 |
+
southwest corner, e.g. "N48E000". All of this project's real station
|
| 17 |
+
coordinates (lon 0.43-1.54E, lat 48.39-49.34N) fall inside exactly one
|
| 18 |
+
tile: N48E000 -- computed directly (floor each coordinate to the
|
| 19 |
+
nearest 3-degree grid line), not guessed.
|
| 20 |
+
|
| 21 |
+
CAVEAT: the exact object key/path within the S3 bucket is reasoned from
|
| 22 |
+
documented ESA WorldCover file-naming conventions
|
| 23 |
+
(ESA_WorldCover_10m_2021_v200_{tile}_Map.tif for classification), NOT
|
| 24 |
+
independently verified against a live S3 listing -- no network access
|
| 25 |
+
in the environment this was written in to confirm it directly. Run
|
| 26 |
+
--check first.
|
| 27 |
+
|
| 28 |
+
NEEDS: rasterio (for reading COG tiles and sampling point values).
|
| 29 |
+
|
| 30 |
+
Usage:
|
| 31 |
+
python -m scripts.fetch_worldcover_landcover --check
|
| 32 |
+
python -m scripts.fetch_worldcover_landcover --stations datasets/station_elevations.csv
|
| 33 |
+
"""
|
| 34 |
+
import argparse
|
| 35 |
+
from pathlib import Path
|
| 36 |
+
|
| 37 |
+
import pandas as pd
|
| 38 |
+
|
| 39 |
+
BUCKET_BASE = "https://esa-worldcover.s3.eu-central-1.amazonaws.com"
|
| 40 |
+
TILE = "N48E000"
|
| 41 |
+
CLASSIFICATION_URL = f"{BUCKET_BASE}/v200/2021/map/ESA_WorldCover_10m_2021_v200_{TILE}_Map.tif"
|
| 42 |
+
|
| 43 |
+
# 11-class legend, from ESA's own product documentation -- needed to make
|
| 44 |
+
# the raw integer codes in the classification raster human-readable.
|
| 45 |
+
LANDCOVER_CLASSES = {
|
| 46 |
+
10: "Tree cover", 20: "Shrubland", 30: "Grassland", 40: "Cropland",
|
| 47 |
+
50: "Built-up", 60: "Bare/sparse vegetation", 70: "Snow and ice",
|
| 48 |
+
80: "Permanent water bodies", 90: "Herbaceous wetland",
|
| 49 |
+
95: "Mangrove", 100: "Moss and lichen",
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def compute_tile_id(lat: float, lon: float) -> str:
|
| 54 |
+
"""3-degree grid tile ID containing (lat, lon), named by SW corner."""
|
| 55 |
+
import math
|
| 56 |
+
tile_lat = math.floor(lat / 3) * 3
|
| 57 |
+
tile_lon = math.floor(lon / 3) * 3
|
| 58 |
+
lat_str = f"N{tile_lat:02d}" if tile_lat >= 0 else f"S{-tile_lat:02d}"
|
| 59 |
+
lon_str = f"E{tile_lon:03d}" if tile_lon >= 0 else f"W{-tile_lon:03d}"
|
| 60 |
+
return f"{lat_str}{lon_str}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def check_access() -> bool:
|
| 64 |
+
try:
|
| 65 |
+
import rasterio
|
| 66 |
+
except ImportError:
|
| 67 |
+
print("rasterio is not installed -- this is required. "
|
| 68 |
+
"pip install rasterio --break-system-packages")
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
print(f"Checking {CLASSIFICATION_URL} ...")
|
| 72 |
+
try:
|
| 73 |
+
with rasterio.open(CLASSIFICATION_URL) as src:
|
| 74 |
+
print(f" OK: opened tile, shape={src.shape}, crs={src.crs}, dtype={src.dtypes[0]}")
|
| 75 |
+
# sample one known real point (a real station's coordinates)
|
| 76 |
+
test_lat, test_lon = 49.03, 0.79
|
| 77 |
+
vals = list(src.sample([(test_lon, test_lat)]))
|
| 78 |
+
print(f" Sample at ({test_lat}, {test_lon}): {vals[0][0]} "
|
| 79 |
+
f"({LANDCOVER_CLASSES.get(int(vals[0][0]), 'unknown class')})")
|
| 80 |
+
return True
|
| 81 |
+
except Exception as e:
|
| 82 |
+
print(f" FAILED: {e}")
|
| 83 |
+
print(f" The object key may not match the real S3 layout. Check the ESA WorldCover "
|
| 84 |
+
f"AWS registry page directly: https://registry.opendata.aws/esa-worldcover/")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def fetch_for_stations(stations_path: Path, output_path: Path) -> None:
|
| 89 |
+
import rasterio
|
| 90 |
+
|
| 91 |
+
stations = pd.read_csv(stations_path)
|
| 92 |
+
lat_col = "latitude" if "latitude" in stations.columns else "lat"
|
| 93 |
+
lon_col = "longitude" if "longitude" in stations.columns else "lon"
|
| 94 |
+
|
| 95 |
+
# Confirm every station actually falls in the hardcoded tile before
|
| 96 |
+
# trusting a single-tile fetch -- if a future node set (e.g. the
|
| 97 |
+
# full ~4,500-node reach graph, not just the 27 gauges) extends
|
| 98 |
+
# beyond N48E000, this needs multiple tiles, not silently wrong data
|
| 99 |
+
# from the one tile that happens to be loaded.
|
| 100 |
+
tile_ids = stations.apply(lambda r: compute_tile_id(r[lat_col], r[lon_col]), axis=1)
|
| 101 |
+
unexpected = tile_ids[tile_ids != TILE].unique()
|
| 102 |
+
if len(unexpected) > 0:
|
| 103 |
+
print(f"WARNING: some stations fall outside tile {TILE}: {list(unexpected)}. "
|
| 104 |
+
f"This script only fetches {TILE} -- results for those stations will be wrong "
|
| 105 |
+
f"or missing. Extend BUCKET fetching to cover {list(unexpected)} too.")
|
| 106 |
+
|
| 107 |
+
with rasterio.open(CLASSIFICATION_URL) as src:
|
| 108 |
+
coords = list(zip(stations[lon_col], stations[lat_col]))
|
| 109 |
+
values = [v[0] for v in src.sample(coords)]
|
| 110 |
+
|
| 111 |
+
out = stations[["station_code"]].copy()
|
| 112 |
+
out["landcover_class_code"] = values
|
| 113 |
+
out["landcover_class_name"] = [LANDCOVER_CLASSES.get(int(v), "unknown") for v in values]
|
| 114 |
+
out.to_csv(output_path, index=False)
|
| 115 |
+
print(f"Saved {len(out)} stations' landcover class to {output_path}")
|
| 116 |
+
print(out["landcover_class_name"].value_counts())
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main() -> None:
|
| 120 |
+
parser = argparse.ArgumentParser(description="Sample ESA WorldCover landcover at station points")
|
| 121 |
+
parser.add_argument("--check", action="store_true")
|
| 122 |
+
parser.add_argument("--stations", type=Path, default=Path("datasets/station_elevations.csv"))
|
| 123 |
+
parser.add_argument("--output", type=Path, default=Path("datasets/worldcover_landcover.csv"))
|
| 124 |
+
args = parser.parse_args()
|
| 125 |
+
|
| 126 |
+
if args.check:
|
| 127 |
+
check_access()
|
| 128 |
+
return
|
| 129 |
+
|
| 130 |
+
fetch_for_stations(args.stations, args.output)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
main()
|
scripts/data_acquisition/fetch_meteofrance_precipitation.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fetches real, directly-measured historical precipitation from
|
| 3 |
+
Météo-France's public "Données climatologiques de base - horaires"
|
| 4 |
+
dataset -- genuine rain-gauge observations, not reanalysis. This
|
| 5 |
+
matters specifically for precipitation: it's known to vary sharply over
|
| 6 |
+
short distances in ways a ~9km reanalysis grid cell (ERA5, used
|
| 7 |
+
elsewhere in this project's climate work) can miss, so a real gauge
|
| 8 |
+
near our actual stations is more authoritative where one exists.
|
| 9 |
+
|
| 10 |
+
CONFIRMED real details (via Météo-France's own dataset page and a
|
| 11 |
+
working third-party example querying the same API, not guessed):
|
| 12 |
+
- Producer: Météo-France, Open Licence 2.0, no authentication needed.
|
| 13 |
+
- Dataset API: https://www.data.gouv.fr/api/1/datasets/
|
| 14 |
+
donnees-climatologiques-de-base-horaires/ -- returns a real JSON
|
| 15 |
+
resource list (title + download url per file).
|
| 16 |
+
- Files are organized by department and decade-scale time period,
|
| 17 |
+
downloadable as compressed CSV (.csv.gz).
|
| 18 |
+
- Real temporal coverage extends to 1908 (varies by source/station),
|
| 19 |
+
covering this project's entire 2013+ study period with room to
|
| 20 |
+
spare.
|
| 21 |
+
|
| 22 |
+
Usage:
|
| 23 |
+
python -m scripts.data_acquisition.fetch_meteofrance_precipitation --check --department 27
|
| 24 |
+
"""
|
| 25 |
+
import argparse
|
| 26 |
+
import gzip
|
| 27 |
+
from io import BytesIO
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
import pandas as pd
|
| 31 |
+
import requests
|
| 32 |
+
|
| 33 |
+
DATASET_API = "https://www.data.gouv.fr/api/1/datasets/donnees-climatologiques-de-base-horaires/"
|
| 34 |
+
|
| 35 |
+
# This project's real study departments -- Eure, Eure-et-Loir, Orne --
|
| 36 |
+
# confirmed earlier in this project via the government bassin-versant
|
| 37 |
+
# documentation and the real station roster.
|
| 38 |
+
STUDY_DEPARTMENTS = ["27", "28", "61"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def list_real_resources() -> list:
|
| 42 |
+
"""Fetches the real, current resource list directly from the dataset
|
| 43 |
+
API -- not a cached or guessed file listing, so this always reflects
|
| 44 |
+
whatever Météo-France currently has published."""
|
| 45 |
+
resp = requests.get(DATASET_API, timeout=30)
|
| 46 |
+
resp.raise_for_status()
|
| 47 |
+
data = resp.json()
|
| 48 |
+
return data.get("resources", [])
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def find_department_resources(resources: list, department: str) -> list:
|
| 52 |
+
"""
|
| 53 |
+
Matches a department code against each real resource's title using
|
| 54 |
+
an explicit delimiter set (space, hyphen, underscore, or string
|
| 55 |
+
start/end) around the number -- confirmed as needing two real fixes,
|
| 56 |
+
not one: a first version only checked underscore/hyphen delimiters
|
| 57 |
+
and missed a realistic space-delimited title ("département 027");
|
| 58 |
+
a second version tried \\b (regex word boundary) instead, which
|
| 59 |
+
looked more robust but Python's \\b treats underscore as a WORD
|
| 60 |
+
character, so it doesn't create a boundary in "departement_27" --
|
| 61 |
+
"_27" reads as one unbroken token to \\b, and the match silently
|
| 62 |
+
fails there too. This explicitly enumerates the real delimiters
|
| 63 |
+
that actually appear in these titles instead of trusting a regex
|
| 64 |
+
primitive whose definition of "boundary" doesn't match what's
|
| 65 |
+
actually being delimited here.
|
| 66 |
+
"""
|
| 67 |
+
import re
|
| 68 |
+
dept_2 = department.zfill(2)
|
| 69 |
+
dept_3 = department.zfill(3)
|
| 70 |
+
delim = r"[ \-_]|^"
|
| 71 |
+
end_delim = r"[ \-_]|$"
|
| 72 |
+
pattern = re.compile(rf"(?:{delim})(?:{re.escape(dept_2)}|{re.escape(dept_3)})(?:{end_delim})")
|
| 73 |
+
return [r for r in resources if pattern.search(r.get("title", ""))]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def parse_decade_range(title: str):
|
| 77 |
+
"""Extracts the (start_year, end_year) a resource title encodes,
|
| 78 |
+
e.g. "HOR_departement_27_periode_1920-1929" -> (1920, 1929). Returns
|
| 79 |
+
None if the title doesn't contain a real recognizable year range,
|
| 80 |
+
rather than guessing."""
|
| 81 |
+
import re
|
| 82 |
+
m = re.search(r"(\d{4})-(\d{4})", title)
|
| 83 |
+
if not m:
|
| 84 |
+
return None
|
| 85 |
+
return int(m.group(1)), int(m.group(2))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def filter_relevant_decades(resources: list, start_year: int, end_year: int) -> list:
|
| 89 |
+
"""Keeps only real resources whose decade range overlaps
|
| 90 |
+
[start_year, end_year] -- no reason to download 1900s data for a
|
| 91 |
+
project starting in 2013."""
|
| 92 |
+
relevant = []
|
| 93 |
+
for r in resources:
|
| 94 |
+
decade = parse_decade_range(r.get("title", ""))
|
| 95 |
+
if decade is None:
|
| 96 |
+
continue
|
| 97 |
+
decade_start, decade_end = decade
|
| 98 |
+
if decade_end >= start_year and decade_start <= end_year:
|
| 99 |
+
relevant.append(r)
|
| 100 |
+
return relevant
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def parse_and_aggregate_daily(csv_bytes: bytes) -> pd.DataFrame:
|
| 104 |
+
"""
|
| 105 |
+
Parses one real downloaded, gzip-decompressed CSV and aggregates
|
| 106 |
+
real hourly RR1 (1-hour precipitation, mm) to daily totals per
|
| 107 |
+
station -- sum, not mean, since precipitation is an accumulated
|
| 108 |
+
quantity (matches the same aggregation choice already used for the
|
| 109 |
+
ECMWF forecast data in fetch_previous_runs.py's aggregate_to_daily).
|
| 110 |
+
|
| 111 |
+
AAAAMMJJHH is the real confirmed column name/format: a 10-digit
|
| 112 |
+
YYYYMMDDHH timestamp, parsed explicitly with that format rather
|
| 113 |
+
than left to pandas' generic date inference, which could silently
|
| 114 |
+
misparse an unusual all-digit string.
|
| 115 |
+
"""
|
| 116 |
+
with gzip.open(BytesIO(csv_bytes)) as f:
|
| 117 |
+
df = pd.read_csv(f, sep=";", usecols=["NUM_POSTE", "NOM_USUEL", "LAT", "LON", "AAAAMMJJHH", "RR1"])
|
| 118 |
+
|
| 119 |
+
df["date"] = pd.to_datetime(df["AAAAMMJJHH"].astype(str), format="%Y%m%d%H").dt.date
|
| 120 |
+
daily = df.groupby(["NUM_POSTE", "NOM_USUEL", "LAT", "LON", "date"], as_index=False)["RR1"].sum(min_count=1)
|
| 121 |
+
daily = daily.rename(columns={"RR1": "precip_mm_daily"})
|
| 122 |
+
return daily
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def fetch_department_daily_precipitation(department: str, start_year: int, end_year: int) -> pd.DataFrame:
|
| 126 |
+
resources = list_real_resources()
|
| 127 |
+
dept_resources = find_department_resources(resources, department)
|
| 128 |
+
relevant = filter_relevant_decades(dept_resources, start_year, end_year)
|
| 129 |
+
print(f" department {department}: {len(relevant)} real decade file(s) overlap "
|
| 130 |
+
f"[{start_year}, {end_year}]")
|
| 131 |
+
|
| 132 |
+
all_daily = []
|
| 133 |
+
for r in relevant:
|
| 134 |
+
print(f" downloading {r['title']} ...")
|
| 135 |
+
try:
|
| 136 |
+
resp = requests.get(r["url"], timeout=120)
|
| 137 |
+
resp.raise_for_status()
|
| 138 |
+
daily = parse_and_aggregate_daily(resp.content)
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f" FAILED for {r['title']}: {e}")
|
| 141 |
+
continue
|
| 142 |
+
# Real files span a full decade -- restrict to the actual
|
| 143 |
+
# requested range so a "2010-2019" file doesn't pull in years
|
| 144 |
+
# before start_year that weren't asked for.
|
| 145 |
+
daily = daily[(pd.to_datetime(daily["date"]).dt.year >= start_year) &
|
| 146 |
+
(pd.to_datetime(daily["date"]).dt.year <= end_year)]
|
| 147 |
+
all_daily.append(daily)
|
| 148 |
+
|
| 149 |
+
if not all_daily:
|
| 150 |
+
return pd.DataFrame(columns=["NUM_POSTE", "NOM_USUEL", "LAT", "LON", "date", "precip_mm_daily"])
|
| 151 |
+
return pd.concat(all_daily, ignore_index=True)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def check_access(department: str) -> bool:
|
| 155 |
+
print(f"Fetching real resource list from {DATASET_API} ...")
|
| 156 |
+
try:
|
| 157 |
+
resources = list_real_resources()
|
| 158 |
+
except Exception as e:
|
| 159 |
+
print(f"FAILED to fetch dataset resource list: {e}")
|
| 160 |
+
return False
|
| 161 |
+
print(f"Real dataset has {len(resources)} total resource(s) listed")
|
| 162 |
+
|
| 163 |
+
matches = find_department_resources(resources, department)
|
| 164 |
+
print(f"Resources matching department {department} (checked both 2-digit and 3-digit forms): {len(matches)}")
|
| 165 |
+
if not matches:
|
| 166 |
+
print("No matches -- printing the first 10 real resource titles so the actual naming "
|
| 167 |
+
"convention can be inspected directly, rather than guessed at again:")
|
| 168 |
+
for r in resources[:10]:
|
| 169 |
+
print(f" {r.get('title')}")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
for r in matches[:5]:
|
| 173 |
+
print(f" {r.get('title')} -> {r.get('url')}")
|
| 174 |
+
|
| 175 |
+
# Download the smallest real match and inspect its actual real columns
|
| 176 |
+
# before trusting any assumption about the precipitation column name.
|
| 177 |
+
test_resource = min(matches, key=lambda r: r.get("filesize") or float("inf"))
|
| 178 |
+
print(f"\nDownloading a real sample file to inspect actual columns: {test_resource.get('title')}")
|
| 179 |
+
try:
|
| 180 |
+
resp = requests.get(test_resource["url"], timeout=60)
|
| 181 |
+
resp.raise_for_status()
|
| 182 |
+
with gzip.open(BytesIO(resp.content)) as f:
|
| 183 |
+
df = pd.read_csv(f, sep=";", nrows=5)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
print(f"FAILED to download or parse the real sample file: {e}")
|
| 186 |
+
return False
|
| 187 |
+
|
| 188 |
+
print(f"Real columns found: {list(df.columns)}")
|
| 189 |
+
if "RR1" in df.columns:
|
| 190 |
+
print("CONFIRMED: 'RR1' (1-hour precipitation) is present, matching the standard convention.")
|
| 191 |
+
else:
|
| 192 |
+
print("'RR1' not found in the real columns above -- the precipitation column has a "
|
| 193 |
+
"different real name here; check the printed list directly rather than assume RR1.")
|
| 194 |
+
print(df.head())
|
| 195 |
+
return True
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def main() -> None:
|
| 199 |
+
parser = argparse.ArgumentParser(description="Fetch real historical precipitation from Météo-France")
|
| 200 |
+
parser.add_argument("--check", action="store_true")
|
| 201 |
+
parser.add_argument("--department", type=str, default="27")
|
| 202 |
+
parser.add_argument("--departments", type=str, nargs="+", default=STUDY_DEPARTMENTS,
|
| 203 |
+
help="Full fetch mode only -- defaults to all three real study departments.")
|
| 204 |
+
parser.add_argument("--start-year", type=int, default=2013)
|
| 205 |
+
parser.add_argument("--end-year", type=int, default=2026)
|
| 206 |
+
parser.add_argument("--output-dir", type=Path, default=Path("datasets/meteofrance_precipitation"))
|
| 207 |
+
args = parser.parse_args()
|
| 208 |
+
|
| 209 |
+
if args.check:
|
| 210 |
+
check_access(args.department)
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 214 |
+
all_departments = []
|
| 215 |
+
for dept in args.departments:
|
| 216 |
+
print(f"Fetching real precipitation for department {dept}...")
|
| 217 |
+
daily = fetch_department_daily_precipitation(dept, args.start_year, args.end_year)
|
| 218 |
+
if daily.empty:
|
| 219 |
+
print(f" no real data retrieved for department {dept}")
|
| 220 |
+
continue
|
| 221 |
+
daily["department"] = dept
|
| 222 |
+
all_departments.append(daily)
|
| 223 |
+
|
| 224 |
+
if not all_departments:
|
| 225 |
+
print("No real data retrieved for any department -- run --check first if this is unexpected.")
|
| 226 |
+
return
|
| 227 |
+
|
| 228 |
+
combined = pd.concat(all_departments, ignore_index=True)
|
| 229 |
+
n_stations = combined["NUM_POSTE"].nunique()
|
| 230 |
+
n_real_values = combined["precip_mm_daily"].notna().sum()
|
| 231 |
+
print(f"\nReal daily precipitation: {n_stations} station(s) across {len(args.departments)} department(s), "
|
| 232 |
+
f"{n_real_values} real day-station value(s)")
|
| 233 |
+
|
| 234 |
+
output_path = args.output_dir / "daily_precipitation.csv"
|
| 235 |
+
combined.to_csv(output_path, index=False)
|
| 236 |
+
print(f"Saved to {output_path}")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
main()
|
scripts/data_acquisition/fetch_previous_runs.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fetches Open-Meteo's Previous Runs API -- real past forecasts at fixed
|
| 3 |
+
lead-time offsets (1-7 days), the piece needed to validate whether
|
| 4 |
+
ECMWF's forecast would genuinely have caught real historical flood
|
| 5 |
+
conditions, without look-ahead leakage. This is the actual prerequisite
|
| 6 |
+
for real bias-correction: comparing what was forecast N days ahead
|
| 7 |
+
against what really happened, per lead time.
|
| 8 |
+
|
| 9 |
+
CONFIRMED real details (via Open-Meteo's own documentation): variable
|
| 10 |
+
suffix pattern `{variable}_previous_day{N}` for N=1..7 (e.g.
|
| 11 |
+
precipitation_sum_previous_day3 = what was forecast 3 days before the
|
| 12 |
+
valid date). Data starts January 2024 for most models; ECMWF IFS HRES
|
| 13 |
+
specifically from March 2024 -- meaning real lead-time-stratified
|
| 14 |
+
validation is only possible for roughly the last 2.5 years, not this
|
| 15 |
+
project's full multi-decade discharge record. Multiple locations can be
|
| 16 |
+
queried in one request via comma-separated coordinate lists.
|
| 17 |
+
|
| 18 |
+
NOT directly confirmed: the exact hostname for this specific API.
|
| 19 |
+
Open-Meteo's two sibling historical products have confirmed hostnames
|
| 20 |
+
following a clear pattern (archive-api.open-meteo.com,
|
| 21 |
+
historical-forecast-api.open-meteo.com) -- "previous-runs-api.open-
|
| 22 |
+
meteo.com" is inferred from that pattern, not verified by a direct
|
| 23 |
+
source. Run --check first; if it 404s, the real hostname needs a fresh
|
| 24 |
+
search rather than a second guess.
|
| 25 |
+
|
| 26 |
+
Usage:
|
| 27 |
+
python -m scripts.fetch_previous_runs --check
|
| 28 |
+
python -m scripts.fetch_previous_runs --stations-path datasets/station_elevations.csv
|
| 29 |
+
"""
|
| 30 |
+
import argparse
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import pandas as pd
|
| 34 |
+
import requests
|
| 35 |
+
|
| 36 |
+
BASE_URL = "https://previous-runs-api.open-meteo.com/v1/forecast" # confirmed real hostname -- see module docstring
|
| 37 |
+
|
| 38 |
+
# HOURLY variable names, not daily aggregates -- confirmed as the real
|
| 39 |
+
# fix, not a second blind guess: every real documentation example of
|
| 40 |
+
# the _previous_dayN suffix used "temperature_2m_previous_day1" (the
|
| 41 |
+
# hourly variable name), never "temperature_2m_max_previous_day1" (a
|
| 42 |
+
# daily aggregate). The first real request against this endpoint used
|
| 43 |
+
# daily aggregate names and got a genuine 400 (invalid variable value),
|
| 44 |
+
# confirming the hostname was right but the parameter convention was
|
| 45 |
+
# wrong. Daily aggregation happens client-side in aggregate_to_daily
|
| 46 |
+
# below, matching this project's own established convention rather than
|
| 47 |
+
# assuming the API supports a suffix pattern it apparently doesn't for
|
| 48 |
+
# daily variables.
|
| 49 |
+
HOURLY_VARIABLES = ["precipitation", "temperature_2m", "wind_speed_10m"]
|
| 50 |
+
LEAD_DAYS = [1, 2, 3, 5, 7]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build_hourly_params(lead_days: list) -> list:
|
| 54 |
+
"""{variable}_previous_day{N} for every requested variable/lead-time
|
| 55 |
+
combination, plus day0 (the run closest to the live forecast) as a
|
| 56 |
+
reference point."""
|
| 57 |
+
params = []
|
| 58 |
+
for var in HOURLY_VARIABLES:
|
| 59 |
+
params.append(f"{var}_previous_day0")
|
| 60 |
+
for n in lead_days:
|
| 61 |
+
params.append(f"{var}_previous_day{n}")
|
| 62 |
+
return params
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def aggregate_to_daily(df: pd.DataFrame) -> pd.DataFrame:
|
| 66 |
+
"""
|
| 67 |
+
Collapses the real hourly response to daily values -- sum for
|
| 68 |
+
precipitation, max for temperature/wind, matching standard
|
| 69 |
+
meteorological daily-aggregate convention and this project's
|
| 70 |
+
existing daily-resolution pipeline (climate_climatology.py etc.).
|
| 71 |
+
"""
|
| 72 |
+
df = df.copy()
|
| 73 |
+
df["date"] = pd.to_datetime(df["time"]).dt.date
|
| 74 |
+
agg_rules = {}
|
| 75 |
+
for col in df.columns:
|
| 76 |
+
if col in ("time", "date", "latitude", "longitude", "station_code"):
|
| 77 |
+
continue
|
| 78 |
+
agg_rules[col] = "sum" if col.startswith("precipitation") else "max"
|
| 79 |
+
daily = df.groupby("date").agg(agg_rules).reset_index()
|
| 80 |
+
return daily
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def check_access() -> bool:
|
| 84 |
+
params = {
|
| 85 |
+
"latitude": 48.748705218, "longitude": 0.58156195,
|
| 86 |
+
"hourly": ",".join(build_hourly_params(LEAD_DAYS)),
|
| 87 |
+
"start_date": "2024-06-01", "end_date": "2024-06-07",
|
| 88 |
+
"timezone": "Europe/Paris",
|
| 89 |
+
}
|
| 90 |
+
print(f"Testing {BASE_URL} with hourly variable names (the confirmed real convention)...")
|
| 91 |
+
try:
|
| 92 |
+
resp = requests.get(BASE_URL, params=params, timeout=30)
|
| 93 |
+
print(f"HTTP status: {resp.status_code}")
|
| 94 |
+
if resp.status_code != 200:
|
| 95 |
+
print(f"Response body: {resp.text[:500]}")
|
| 96 |
+
return False
|
| 97 |
+
data = resp.json()
|
| 98 |
+
except Exception as e:
|
| 99 |
+
print(f"FAILED: {e}")
|
| 100 |
+
return False
|
| 101 |
+
|
| 102 |
+
if "hourly" not in data:
|
| 103 |
+
print(f"FAILED: no 'hourly' key in response. Full response: {data}")
|
| 104 |
+
return False
|
| 105 |
+
|
| 106 |
+
print("OK -- real lead-time-stratified hourly data returned:")
|
| 107 |
+
for key in list(data["hourly"].keys())[:6]:
|
| 108 |
+
print(f" {key}: {data['hourly'][key][:3]}...")
|
| 109 |
+
return True
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def fetch_previous_runs(lat: float, lon: float, start_date: str, end_date: str,
|
| 113 |
+
lead_days: list = LEAD_DAYS) -> pd.DataFrame:
|
| 114 |
+
params = {
|
| 115 |
+
"latitude": lat, "longitude": lon,
|
| 116 |
+
"hourly": ",".join(build_hourly_params(lead_days)),
|
| 117 |
+
"start_date": start_date, "end_date": end_date,
|
| 118 |
+
"timezone": "Europe/Paris",
|
| 119 |
+
}
|
| 120 |
+
resp = requests.get(BASE_URL, params=params, timeout=60)
|
| 121 |
+
resp.raise_for_status()
|
| 122 |
+
data = resp.json()
|
| 123 |
+
if "hourly" not in data:
|
| 124 |
+
raise ValueError(f"No 'hourly' data for ({lat}, {lon}): {data}")
|
| 125 |
+
|
| 126 |
+
df = pd.DataFrame(data["hourly"])
|
| 127 |
+
df["latitude"] = lat
|
| 128 |
+
df["longitude"] = lon
|
| 129 |
+
return df
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> None:
|
| 134 |
+
parser = argparse.ArgumentParser(description="Fetch Open-Meteo Previous Runs data for bias-correction validation")
|
| 135 |
+
parser.add_argument("--check", action="store_true")
|
| 136 |
+
parser.add_argument("--stations-path", type=Path, default=Path("datasets/station_elevations.csv"))
|
| 137 |
+
parser.add_argument("--start-date", type=str, default="2024-03-01",
|
| 138 |
+
help="Defaults to when ECMWF IFS HRES coverage begins in this archive")
|
| 139 |
+
parser.add_argument("--end-date", type=str, default="2026-08-30")
|
| 140 |
+
parser.add_argument("--output", type=Path, default=Path("datasets/previous_runs_forecast.csv"))
|
| 141 |
+
args = parser.parse_args()
|
| 142 |
+
|
| 143 |
+
if args.check:
|
| 144 |
+
check_access()
|
| 145 |
+
return
|
| 146 |
+
|
| 147 |
+
stations = pd.read_csv(args.stations_path)
|
| 148 |
+
lat_col = "latitude" if "latitude" in stations.columns else "lat"
|
| 149 |
+
lon_col = "longitude" if "longitude" in stations.columns else "lon"
|
| 150 |
+
|
| 151 |
+
all_dfs = []
|
| 152 |
+
for _, row in stations.iterrows():
|
| 153 |
+
code = row.get("station_code", f"{row[lat_col]:.3f}_{row[lon_col]:.3f}")
|
| 154 |
+
print(f"Fetching real previous-runs data for {code}...")
|
| 155 |
+
try:
|
| 156 |
+
df = fetch_previous_runs(row[lat_col], row[lon_col], args.start_date, args.end_date)
|
| 157 |
+
df = aggregate_to_daily(df)
|
| 158 |
+
df["station_code"] = code
|
| 159 |
+
all_dfs.append(df)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
print(f" FAILED for {code}: {e}")
|
| 162 |
+
|
| 163 |
+
if not all_dfs:
|
| 164 |
+
print("No real data fetched -- run --check first if every station failed.")
|
| 165 |
+
return
|
| 166 |
+
|
| 167 |
+
combined = pd.concat(all_dfs, ignore_index=True)
|
| 168 |
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
| 169 |
+
combined.to_csv(args.output, index=False)
|
| 170 |
+
print(f"\nSaved real lead-time-stratified forecast history for "
|
| 171 |
+
f"{combined['station_code'].nunique()} station(s) to {args.output}")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == "__main__":
|
| 175 |
+
main()
|
scripts/data_acquisition/fetch_worldcover_ndvi.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Samples ESA WorldCover's Sentinel-2 NDVI yearly percentile composite
|
| 3 |
+
(p10/p50/p90) at station points, via the public S3 COGs.
|
| 4 |
+
|
| 5 |
+
Unlike fetch_worldcover_landcover.py, this does NOT hand-compute the
|
| 6 |
+
tile ID and guess the S3 key pattern -- VITO provides an authoritative
|
| 7 |
+
grid file for exactly this purpose (found directly, not guessed):
|
| 8 |
+
https://esa-worldcover.s3.eu-central-1.amazonaws.com/esa_worldcover_grid_composites.fgb
|
| 9 |
+
This is a FlatGeobuf containing one feature per 1x1 degree composite
|
| 10 |
+
tile, with (per VITO's own documentation) the real S3 path/URL for
|
| 11 |
+
each tile as an attribute -- reading it means never guessing a file
|
| 12 |
+
naming convention, which is exactly the class of guess that failed for
|
| 13 |
+
the IDPR WMS layer name earlier in this session.
|
| 14 |
+
|
| 15 |
+
The composites use a 1x1 degree grid, NOT the 3x3 degree grid the
|
| 16 |
+
landcover classification map uses -- so unlike fetch_worldcover_
|
| 17 |
+
landcover.py's single hardcoded tile, station coordinates spanning
|
| 18 |
+
more than 1 degree in either direction genuinely need more than one
|
| 19 |
+
tile here. This script looks up each station's tile individually
|
| 20 |
+
rather than assuming one tile covers everyone.
|
| 21 |
+
|
| 22 |
+
NEEDS: geopandas (to read the grid FlatGeobuf) and rasterio (to sample
|
| 23 |
+
the COGs). CAVEAT: the grid file's actual column names/schema are not
|
| 24 |
+
independently verified here (no network access to inspect it directly)
|
| 25 |
+
-- the script prints all columns and searches for a plausible URL/path
|
| 26 |
+
column rather than assuming an exact name, and reports clearly if it
|
| 27 |
+
can't find one.
|
| 28 |
+
|
| 29 |
+
Usage:
|
| 30 |
+
python -m scripts.fetch_worldcover_ndvi --check
|
| 31 |
+
python -m scripts.fetch_worldcover_ndvi --stations datasets/station_elevations.csv
|
| 32 |
+
"""
|
| 33 |
+
import argparse
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import pandas as pd
|
| 37 |
+
|
| 38 |
+
GRID_URL = "https://esa-worldcover.s3.eu-central-1.amazonaws.com/esa_worldcover_grid_composites.fgb"
|
| 39 |
+
|
| 40 |
+
# The grid file's tile URLs use the s3:// scheme (confirmed against real
|
| 41 |
+
# output: s3://esa-worldcover-s2/ndvi/2020/N48/...) -- GDAL's S3 driver
|
| 42 |
+
# tries to SIGN requests with real AWS credentials by default even
|
| 43 |
+
# though this bucket is fully public, unlike the HTTPS URL
|
| 44 |
+
# fetch_worldcover_landcover.py used (a plain HTTPS GET needs no
|
| 45 |
+
# signing at all, which is why that one worked without this). This
|
| 46 |
+
# environment variable is the standard fix for reading a public S3
|
| 47 |
+
# bucket without needing real credentials.
|
| 48 |
+
import os
|
| 49 |
+
os.environ.setdefault("AWS_NO_SIGN_REQUEST", "YES")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def load_grid():
|
| 53 |
+
import geopandas as gpd
|
| 54 |
+
print(f"Loading tile grid from {GRID_URL} ...")
|
| 55 |
+
grid = gpd.read_file(GRID_URL)
|
| 56 |
+
print(f" {len(grid)} tile(s), columns: {list(grid.columns)}")
|
| 57 |
+
return grid
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def find_url_column(grid) -> str:
|
| 61 |
+
"""
|
| 62 |
+
The grid's real column name for the tile's S3 path isn't
|
| 63 |
+
independently confirmed -- search for a plausible one rather than
|
| 64 |
+
assume, and fail loudly (not silently) if nothing matches.
|
| 65 |
+
"""
|
| 66 |
+
candidates = [c for c in grid.columns if any(
|
| 67 |
+
kw in c.lower() for kw in ("url", "href", "path", "s3", "ndvi", "product", "file")
|
| 68 |
+
)]
|
| 69 |
+
if not candidates:
|
| 70 |
+
raise ValueError(
|
| 71 |
+
f"No column looks like a tile URL/path among {list(grid.columns)}. "
|
| 72 |
+
f"Inspect the grid file's real schema directly (e.g. print(grid.head()) "
|
| 73 |
+
f"in a notebook) and adjust find_url_column accordingly."
|
| 74 |
+
)
|
| 75 |
+
print(f" candidate URL/path column(s): {candidates} -- using {candidates[0]!r}")
|
| 76 |
+
return candidates[0]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def check_access() -> bool:
|
| 80 |
+
try:
|
| 81 |
+
import geopandas # noqa: F401
|
| 82 |
+
import rasterio # noqa: F401
|
| 83 |
+
except ImportError as e:
|
| 84 |
+
print(f"Missing dependency: {e}. pip install geopandas rasterio --break-system-packages")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
grid = load_grid()
|
| 89 |
+
url_col = find_url_column(grid)
|
| 90 |
+
print(f" Sample values from {url_col!r}: {grid[url_col].head(3).tolist()}")
|
| 91 |
+
return True
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f" FAILED: {e}")
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def fetch_for_stations(stations_path: Path, output_path: Path) -> None:
|
| 98 |
+
import geopandas as gpd
|
| 99 |
+
import rasterio
|
| 100 |
+
from shapely.geometry import Point
|
| 101 |
+
|
| 102 |
+
stations = pd.read_csv(stations_path)
|
| 103 |
+
lat_col = "latitude" if "latitude" in stations.columns else "lat"
|
| 104 |
+
lon_col = "longitude" if "longitude" in stations.columns else "lon"
|
| 105 |
+
|
| 106 |
+
grid = load_grid()
|
| 107 |
+
url_col = find_url_column(grid)
|
| 108 |
+
|
| 109 |
+
station_points = gpd.GeoDataFrame(
|
| 110 |
+
stations,
|
| 111 |
+
geometry=[Point(lon, lat) for lon, lat in zip(stations[lon_col], stations[lat_col])],
|
| 112 |
+
crs=grid.crs,
|
| 113 |
+
)
|
| 114 |
+
joined = gpd.sjoin(station_points, grid[[url_col, "geometry"]], how="left", predicate="within")
|
| 115 |
+
|
| 116 |
+
rows = []
|
| 117 |
+
for tile_url, group in joined.groupby(url_col):
|
| 118 |
+
if pd.isna(tile_url):
|
| 119 |
+
for _, row in group.iterrows():
|
| 120 |
+
rows.append({"station_code": row["station_code"], "ndvi_p10": None,
|
| 121 |
+
"ndvi_p50": None, "ndvi_p90": None})
|
| 122 |
+
continue
|
| 123 |
+
print(f" opening {tile_url} for {len(group)} station(s)...")
|
| 124 |
+
with rasterio.Env(AWS_NO_SIGN_REQUEST="YES"), rasterio.open(tile_url) as src:
|
| 125 |
+
coords = list(zip(group[lon_col], group[lat_col]))
|
| 126 |
+
values = list(src.sample(coords))
|
| 127 |
+
for (_, row), val in zip(group.iterrows(), values):
|
| 128 |
+
# NDVI percentile composite is documented as 3 bands: p90, p50, p10
|
| 129 |
+
rows.append({"station_code": row["station_code"],
|
| 130 |
+
"ndvi_p90": float(val[0]) if len(val) > 0 else None,
|
| 131 |
+
"ndvi_p50": float(val[1]) if len(val) > 1 else None,
|
| 132 |
+
"ndvi_p10": float(val[2]) if len(val) > 2 else None})
|
| 133 |
+
|
| 134 |
+
out = pd.DataFrame(rows)
|
| 135 |
+
out.to_csv(output_path, index=False)
|
| 136 |
+
print(f"Saved {len(out)} stations' NDVI values to {output_path}")
|
| 137 |
+
print(out.describe())
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def main() -> None:
|
| 141 |
+
parser = argparse.ArgumentParser(description="Sample ESA WorldCover NDVI at station points")
|
| 142 |
+
parser.add_argument("--check", action="store_true")
|
| 143 |
+
parser.add_argument("--stations", type=Path, default=Path("datasets/station_elevations.csv"))
|
| 144 |
+
parser.add_argument("--output", type=Path, default=Path("datasets/worldcover_ndvi.csv"))
|
| 145 |
+
args = parser.parse_args()
|
| 146 |
+
|
| 147 |
+
if args.check:
|
| 148 |
+
check_access()
|
| 149 |
+
return
|
| 150 |
+
|
| 151 |
+
fetch_for_stations(args.stations, args.output)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
main()
|
scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc
ADDED
|
Binary file (55.8 kB). View file
|
|
|
scripts/evaluation/evaluate_flood_detection.py
ADDED
|
@@ -0,0 +1,986 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluates whether the currently trained spatiotemporal GNN would have
|
| 3 |
+
flagged the real historical high-flow events identified by
|
| 4 |
+
identify_flood_events.py. This is the test that actually matters for
|
| 5 |
+
this project's stated purpose -- beating a naive mean-baseline says
|
| 6 |
+
the model learned real structure, but says nothing about whether it
|
| 7 |
+
would catch a real flood. Those are different questions, and only
|
| 8 |
+
this one answers the second.
|
| 9 |
+
|
| 10 |
+
Reuses the exact data pipeline from train_spatiotemporal_gnn.py (same
|
| 11 |
+
subgraph selection, same standardization) rather than rebuilding it
|
| 12 |
+
independently -- the model's saved weights only make sense against the
|
| 13 |
+
exact input shapes and standardization statistics they were trained
|
| 14 |
+
with, so any mismatch here would silently produce meaningless
|
| 15 |
+
predictions rather than a clean error.
|
| 16 |
+
|
| 17 |
+
For every real (anchor_date, node, horizon) combination in the held-out
|
| 18 |
+
test split, checks whether the model's own discharge prediction --
|
| 19 |
+
converted back to real L/s, not left in standardized units -- exceeds
|
| 20 |
+
that station's real event threshold (the same threshold
|
| 21 |
+
identify_flood_events.py used to define a "high-flow day" there).
|
| 22 |
+
Confusion matrix is computed per horizon: catching a real event a day
|
| 23 |
+
out and catching it a week out are different questions, and averaging
|
| 24 |
+
them together would hide which lead times the model is actually
|
| 25 |
+
useful at.
|
| 26 |
+
|
| 27 |
+
Usage:
|
| 28 |
+
python -m scripts.evaluation.evaluate_flood_detection --data-root datasets
|
| 29 |
+
"""
|
| 30 |
+
import argparse
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Dict, List
|
| 33 |
+
|
| 34 |
+
import numpy as np
|
| 35 |
+
import pandas as pd
|
| 36 |
+
import torch
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from scripts.training.train_spatiotemporal_gnn import (
|
| 40 |
+
BASIN_FILE_NAMES, HORIZONS, QUANTILES, load_basin_graph, combine_basins,
|
| 41 |
+
build_combined_dynamic_tensors, standardize_dynamic_tensors, standardize_static_features,
|
| 42 |
+
extract_catchment_area_km2,
|
| 43 |
+
compute_rolling_precip_sum, fit_specific_discharge_coefficient, estimate_discharge_from_precip,
|
| 44 |
+
build_forecast_tensor, compute_days_since_last_real, compress_days_since_last_real,
|
| 45 |
+
get_dynamic_channel_index, ROUTING_HORIZON_COUNT, FORECAST_LEAD_TIMES,
|
| 46 |
+
)
|
| 47 |
+
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 48 |
+
from src.graph.spatiotemporal_gnn import prepare_graph_training_windows, SpatiotemporalGNN
|
| 49 |
+
except ImportError:
|
| 50 |
+
import sys
|
| 51 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
| 52 |
+
from scripts.training.train_spatiotemporal_gnn import (
|
| 53 |
+
BASIN_FILE_NAMES, HORIZONS, QUANTILES, load_basin_graph, combine_basins,
|
| 54 |
+
build_combined_dynamic_tensors, standardize_dynamic_tensors, standardize_static_features,
|
| 55 |
+
extract_catchment_area_km2,
|
| 56 |
+
compute_rolling_precip_sum, fit_specific_discharge_coefficient, estimate_discharge_from_precip,
|
| 57 |
+
build_forecast_tensor, compute_days_since_last_real, compress_days_since_last_real,
|
| 58 |
+
get_dynamic_channel_index, ROUTING_HORIZON_COUNT, FORECAST_LEAD_TIMES,
|
| 59 |
+
)
|
| 60 |
+
from src.graph.subgraph_selection import build_collapsed_subgraph
|
| 61 |
+
from src.graph.spatiotemporal_gnn import prepare_graph_training_windows, SpatiotemporalGNN
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def build_event_lookup(events_df: pd.DataFrame) -> dict:
|
| 65 |
+
"""
|
| 66 |
+
Real event date ranges, indexed by station, for fast membership
|
| 67 |
+
checks -- {station_code: [(start_date, end_date, threshold), ...]}.
|
| 68 |
+
A single station can have several real events across the record, so
|
| 69 |
+
this stays a list per station rather than assuming at most one.
|
| 70 |
+
"""
|
| 71 |
+
lookup = {}
|
| 72 |
+
for _, row in events_df.iterrows():
|
| 73 |
+
lookup.setdefault(row["station_code"], []).append(
|
| 74 |
+
(pd.Timestamp(row["start_date"]), pd.Timestamp(row["end_date"]), row["threshold"])
|
| 75 |
+
)
|
| 76 |
+
return lookup
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def is_real_event_day(station_code: str, date: pd.Timestamp, event_lookup: dict):
|
| 80 |
+
"""
|
| 81 |
+
Returns the real threshold if `date` falls within a real event for
|
| 82 |
+
this station, else None -- distinguishing "not an event" from
|
| 83 |
+
"no threshold defined" matters for how the caller should treat it,
|
| 84 |
+
so this returns the threshold itself rather than a bare boolean.
|
| 85 |
+
"""
|
| 86 |
+
for start, end, threshold in event_lookup.get(station_code, []):
|
| 87 |
+
if start <= date <= end:
|
| 88 |
+
return threshold
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def days_to_nearest_real_event(station_code: str, date: pd.Timestamp, event_lookup: dict) -> "float":
|
| 93 |
+
"""
|
| 94 |
+
Real, signed distance in days from `date` to the nearest real event
|
| 95 |
+
window for this station -- 0 if `date` falls inside one. Built to
|
| 96 |
+
distinguish a real false positive that's a near-miss (a day or two
|
| 97 |
+
from an actual real event) from one with no real event anywhere
|
| 98 |
+
nearby, which are genuinely different situations worth telling
|
| 99 |
+
apart when inspecting false positives directly.
|
| 100 |
+
|
| 101 |
+
Returns float("inf") if this station has no real events at all.
|
| 102 |
+
"""
|
| 103 |
+
best = float("inf")
|
| 104 |
+
for start, end, _ in event_lookup.get(station_code, []):
|
| 105 |
+
if start <= date <= end:
|
| 106 |
+
return 0.0
|
| 107 |
+
gap = min(abs((date - start).days), abs((date - end).days))
|
| 108 |
+
best = min(best, gap)
|
| 109 |
+
return best
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def build_station_thresholds(events_df: pd.DataFrame) -> dict:
|
| 113 |
+
"""
|
| 114 |
+
One real threshold per station -- identify_flood_events.py computes
|
| 115 |
+
this once per station (that station's own historical percentile),
|
| 116 |
+
so every real event row for a given station carries the same value;
|
| 117 |
+
this collapses that back down to {station_code: threshold}.
|
| 118 |
+
|
| 119 |
+
This threshold is what gets compared against EVERY day's
|
| 120 |
+
prediction, event or not -- comparing only on real event days (and
|
| 121 |
+
treating every other day as an automatic non-flag) would make false
|
| 122 |
+
positives structurally impossible and precision meaningless by
|
| 123 |
+
construction, not just optimistic.
|
| 124 |
+
"""
|
| 125 |
+
return events_df.groupby("station_code")["threshold"].first().to_dict()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def plot_confusion_matrices_by_horizon(confusion: dict, horizons: list, output_path: Path) -> None:
|
| 129 |
+
"""
|
| 130 |
+
A real 2x2 confusion matrix heatmap per horizon, arranged in a grid
|
| 131 |
+
-- gives an immediate visual read on how tp/fp/fn/tn actually shift
|
| 132 |
+
across lead times, rather than reading raw counts out of a printed
|
| 133 |
+
table.
|
| 134 |
+
"""
|
| 135 |
+
import matplotlib.pyplot as plt
|
| 136 |
+
|
| 137 |
+
n_horizons = len(horizons)
|
| 138 |
+
n_cols = min(5, n_horizons)
|
| 139 |
+
n_rows = (n_horizons + n_cols - 1) // n_cols
|
| 140 |
+
fig, axes = plt.subplots(n_rows, n_cols, figsize=(3.2 * n_cols, 3.2 * n_rows))
|
| 141 |
+
axes = axes.flatten() if n_horizons > 1 else [axes]
|
| 142 |
+
|
| 143 |
+
for idx, h in enumerate(horizons):
|
| 144 |
+
ax = axes[idx]
|
| 145 |
+
c = confusion[h]
|
| 146 |
+
# rows = actual (event, no event), cols = predicted (flag, no flag)
|
| 147 |
+
matrix = [[c["tp"], c["fn"]], [c["fp"], c["tn"]]]
|
| 148 |
+
im = ax.imshow(matrix, cmap="Blues", vmin=0)
|
| 149 |
+
for i in range(2):
|
| 150 |
+
for j in range(2):
|
| 151 |
+
value = matrix[i][j]
|
| 152 |
+
# Text color flips for readability against the
|
| 153 |
+
# colormap's own darker high-value cells.
|
| 154 |
+
text_color = "white" if value > (max(max(row) for row in matrix) / 2) else "black"
|
| 155 |
+
ax.text(j, i, str(value), ha="center", va="center", color=text_color, fontsize=11)
|
| 156 |
+
ax.set_xticks([0, 1])
|
| 157 |
+
ax.set_yticks([0, 1])
|
| 158 |
+
ax.set_xticklabels(["flagged", "not flagged"], fontsize=8)
|
| 159 |
+
ax.set_yticklabels(["event", "no event"], fontsize=8)
|
| 160 |
+
ax.set_title(f"{h}-day", fontsize=10)
|
| 161 |
+
|
| 162 |
+
for idx in range(n_horizons, len(axes)):
|
| 163 |
+
axes[idx].axis("off")
|
| 164 |
+
|
| 165 |
+
fig.suptitle("Flood-detection confusion matrix by lead time (real held-out test data)")
|
| 166 |
+
fig.tight_layout()
|
| 167 |
+
fig.savefig(output_path, dpi=150)
|
| 168 |
+
plt.close(fig)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def plot_precision_recall_by_horizon(result_df: "object", output_path: Path) -> None:
|
| 172 |
+
"""
|
| 173 |
+
Precision, recall, and the real base rate, all against horizon on
|
| 174 |
+
one plot -- deliberately including base rate directly rather than
|
| 175 |
+
leaving it as a separate manual calculation, since this project has
|
| 176 |
+
repeatedly found that a horizon's precision only means something
|
| 177 |
+
when read against how often a real event actually occurs there (a
|
| 178 |
+
horizon with very few real events can show inflated-looking
|
| 179 |
+
precision/recall from a handful of lucky flags).
|
| 180 |
+
"""
|
| 181 |
+
import matplotlib.pyplot as plt
|
| 182 |
+
|
| 183 |
+
horizons = result_df["horizon_days"].values
|
| 184 |
+
precision = result_df["precision"].values
|
| 185 |
+
recall = result_df["recall"].values
|
| 186 |
+
base_rate = (result_df["tp"] + result_df["fn"]) / (
|
| 187 |
+
result_df["tp"] + result_df["fp"] + result_df["fn"] + result_df["tn"]
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
fig, ax = plt.subplots(figsize=(9, 5))
|
| 191 |
+
x_pos = range(len(horizons)) # evenly spaced positions -- HORIZONS jumps unevenly
|
| 192 |
+
# (5 -> 10 -> 30 -> 60 -> 90 -> 180), so a real numeric x-axis would
|
| 193 |
+
# compress the 1-10 day range (where most of the real signal lives)
|
| 194 |
+
# into a small sliver of the plot.
|
| 195 |
+
ax.plot(x_pos, precision, marker="o", label="precision", color="#2563eb")
|
| 196 |
+
ax.plot(x_pos, recall, marker="o", label="recall", color="#16a34a")
|
| 197 |
+
ax.plot(x_pos, base_rate, marker="o", label="real event base rate", color="#94a3b8", linestyle="--")
|
| 198 |
+
ax.set_xticks(list(x_pos))
|
| 199 |
+
ax.set_xticklabels([str(h) for h in horizons])
|
| 200 |
+
ax.set_xlabel("Lead time (days)")
|
| 201 |
+
ax.set_ylabel("Rate")
|
| 202 |
+
ax.set_ylim(-0.02, 1.02)
|
| 203 |
+
ax.set_title("Precision/recall vs. lead time, against the real event base rate")
|
| 204 |
+
ax.legend(loc="best")
|
| 205 |
+
ax.grid(True, alpha=0.3)
|
| 206 |
+
fig.tight_layout()
|
| 207 |
+
fig.savefig(output_path, dpi=150)
|
| 208 |
+
plt.close(fig)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def plot_single_trace(
|
| 212 |
+
horizons: list, quantiles: list, quantile_values_real: "object",
|
| 213 |
+
threshold: float, routing_horizon_count: int, station_code: str, anchor_str: str,
|
| 214 |
+
output_path: Path,
|
| 215 |
+
) -> None:
|
| 216 |
+
"""
|
| 217 |
+
Real predicted discharge, every quantile, across every real
|
| 218 |
+
horizon, for one specific (anchor, station) example -- with the
|
| 219 |
+
real station threshold and the real ROUTING_HORIZON_COUNT boundary
|
| 220 |
+
both marked directly on the plot. Built specifically to check for a
|
| 221 |
+
real visual discontinuity right at the physics-loss boundary, which
|
| 222 |
+
an aggregate statistic (mean/ratio across many examples) could
|
| 223 |
+
smooth over or miss entirely.
|
| 224 |
+
"""
|
| 225 |
+
import matplotlib.pyplot as plt
|
| 226 |
+
|
| 227 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 228 |
+
x_pos = range(len(horizons))
|
| 229 |
+
colors = {0.5: "#2563eb", 0.9: "#f59e0b", 0.95: "#dc2626", 0.99: "#7c3aed"}
|
| 230 |
+
for q_idx, q in enumerate(quantiles):
|
| 231 |
+
ax.plot(x_pos, quantile_values_real[:, q_idx], marker="o",
|
| 232 |
+
label=f"quantile {q}", color=colors.get(q, "#333333"))
|
| 233 |
+
|
| 234 |
+
ax.axhline(threshold, color="#16a34a", linestyle="--", linewidth=1.5, label="real station threshold")
|
| 235 |
+
|
| 236 |
+
# Real ROUTING_HORIZON_COUNT boundary -- physics losses (routing,
|
| 237 |
+
# soft-DTW, water balance) apply up through this horizon and no
|
| 238 |
+
# further; marked as a real, vertical reference line, not just
|
| 239 |
+
# mentioned in the title, so it's directly visible against whatever
|
| 240 |
+
# the quantile lines actually do there.
|
| 241 |
+
if routing_horizon_count in horizons:
|
| 242 |
+
boundary_x = horizons.index(routing_horizon_count)
|
| 243 |
+
ax.axvline(boundary_x, color="#94a3b8", linestyle=":", linewidth=1.5,
|
| 244 |
+
label=f"ROUTING_HORIZON_COUNT boundary ({routing_horizon_count}d)")
|
| 245 |
+
|
| 246 |
+
ax.set_xticks(list(x_pos))
|
| 247 |
+
ax.set_xticklabels([str(h) for h in horizons])
|
| 248 |
+
ax.set_xlabel("Lead time (days)")
|
| 249 |
+
ax.set_ylabel("Discharge (real L/s)")
|
| 250 |
+
ax.set_title(f"Real predicted discharge quantiles -- station {station_code}, anchor {anchor_str}")
|
| 251 |
+
ax.legend(loc="best", fontsize=9)
|
| 252 |
+
ax.grid(True, alpha=0.3)
|
| 253 |
+
fig.tight_layout()
|
| 254 |
+
fig.savefig(output_path, dpi=150)
|
| 255 |
+
plt.close(fig)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def plot_station_timeseries(
|
| 259 |
+
dates_list: list, real_observed: "object", predicted_median: "object", predicted_q95: "object",
|
| 260 |
+
naive_median: float, naive_q95: float, threshold: float, station_code: str, horizon: int,
|
| 261 |
+
output_path: Path,
|
| 262 |
+
) -> None:
|
| 263 |
+
"""
|
| 264 |
+
Real observed discharge, the model's real predicted median/0.95
|
| 265 |
+
quantile, and a real per-station naive baseline, all plotted
|
| 266 |
+
together across the real test period for one station at one fixed
|
| 267 |
+
horizon -- a direct, visual, station-specific complement to the
|
| 268 |
+
aggregate confusion-matrix metrics.
|
| 269 |
+
|
| 270 |
+
The naive baseline is a station-specific value (that station's own
|
| 271 |
+
real training-period empirical median/0.95), not the single global
|
| 272 |
+
naive baseline used for the overall skill percentage elsewhere in
|
| 273 |
+
this project -- a global naive value mixes together stations with
|
| 274 |
+
very different real flow scales, which would make this specific,
|
| 275 |
+
per-station comparison less meaningful, not more.
|
| 276 |
+
|
| 277 |
+
real_observed may be NaN on many real dates (only real gauges with
|
| 278 |
+
real observations ever have a value) -- plotted as a real, genuine
|
| 279 |
+
gap in the line via matplotlib's own NaN handling, not filled or
|
| 280 |
+
interpolated, since a filled gap would misrepresent what's actually
|
| 281 |
+
known versus not.
|
| 282 |
+
"""
|
| 283 |
+
import matplotlib.pyplot as plt
|
| 284 |
+
|
| 285 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
| 286 |
+
ax.plot(dates_list, real_observed, color="#16a34a", linewidth=1.5, marker="o", markersize=3,
|
| 287 |
+
label="real observed discharge")
|
| 288 |
+
ax.plot(dates_list, predicted_median, color="#2563eb", linewidth=1.5, label="predicted median")
|
| 289 |
+
ax.plot(dates_list, predicted_q95, color="#dc2626", linewidth=1.5, label="predicted 0.95 quantile")
|
| 290 |
+
ax.axhline(naive_median, color="#2563eb", linestyle=":", linewidth=1.2, alpha=0.7,
|
| 291 |
+
label="naive baseline (station median)")
|
| 292 |
+
ax.axhline(naive_q95, color="#dc2626", linestyle=":", linewidth=1.2, alpha=0.7,
|
| 293 |
+
label="naive baseline (station 0.95)")
|
| 294 |
+
ax.axhline(threshold, color="#7c3aed", linestyle="--", linewidth=1.5, label="real station threshold")
|
| 295 |
+
|
| 296 |
+
ax.set_xlabel("Real target date")
|
| 297 |
+
ax.set_ylabel("Discharge (real L/s)")
|
| 298 |
+
ax.set_title(f"Station {station_code} -- real observed vs. predicted vs. naive baseline, horizon={horizon}d")
|
| 299 |
+
ax.legend(loc="best", fontsize=9)
|
| 300 |
+
ax.grid(True, alpha=0.3)
|
| 301 |
+
fig.autofmt_xdate()
|
| 302 |
+
fig.tight_layout()
|
| 303 |
+
fig.savefig(output_path, dpi=150)
|
| 304 |
+
plt.close(fig)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def main() -> None:
|
| 308 |
+
parser = argparse.ArgumentParser(description="Evaluate flood-event detection against the trained model")
|
| 309 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 310 |
+
parser.add_argument("--model-path", type=Path, default=None,
|
| 311 |
+
help="Defaults to <data-root>/spatiotemporal_gnn/model.pt")
|
| 312 |
+
parser.add_argument("--events-path", type=Path, default=None,
|
| 313 |
+
help="Defaults to <data-root>/flood_events_discharge.csv")
|
| 314 |
+
parser.add_argument("--lookback-days", type=int, default=30)
|
| 315 |
+
parser.add_argument("--train-end", type=str, default="2021-12-31")
|
| 316 |
+
parser.add_argument("--val-end", type=str, default="2023-12-31")
|
| 317 |
+
parser.add_argument("--test-end", type=str, default="2026-12-31")
|
| 318 |
+
parser.add_argument("--max-nodes", type=int, default=100)
|
| 319 |
+
parser.add_argument("--flag-quantile", type=float, default=0.95,
|
| 320 |
+
help="Which of the model's predicted quantiles counts as a real flood flag, "
|
| 321 |
+
"for any horizon not covered by --flag-quantile-overrides. Defaults to "
|
| 322 |
+
"0.95, matching identify_flood_events.py's own default percentile "
|
| 323 |
+
"threshold -- a coherent, matched comparison.")
|
| 324 |
+
parser.add_argument("--flag-quantile-overrides", type=str, default=None,
|
| 325 |
+
help="Per-horizon overrides, format 'h1=q1,h2=q2,...', e.g. "
|
| 326 |
+
"'1=0.9,2=0.9,3=0.9,4=0.9,5=0.9'. Real, measured evidence in this "
|
| 327 |
+
"project shows the right operating point genuinely differs by horizon: "
|
| 328 |
+
"at short horizons (1-5 days) here, a real predicted quantile is a real "
|
| 329 |
+
"VALUE compared against a fixed real threshold -- since higher quantiles "
|
| 330 |
+
"are, by the model's own monotonicity guarantee, always larger numbers, "
|
| 331 |
+
"a HIGHER quantile makes the threshold EASIER to exceed, not harder (the "
|
| 332 |
+
"opposite of what \"more confidence required\" might suggest). Lowering "
|
| 333 |
+
"toward 0.9 measurably improved precision at short horizons in real "
|
| 334 |
+
"testing (e.g. day 1: 63.8%% -> 67.9%%, day 5: 9.5%% -> 42.4%%) but "
|
| 335 |
+
"collapsed recall to near-zero at longer horizons (day 30 recall hit "
|
| 336 |
+
"0%% at 0.9, back when 30 was still a real predicted horizon -- HORIZONS "
|
| 337 |
+
"has since been narrowed to max out at 21 days, but the real underlying "
|
| 338 |
+
"finding stands) -- there's real evidence against one global value being "
|
| 339 |
+
"right for every horizon. Horizons not listed here fall back to "
|
| 340 |
+
"--flag-quantile.")
|
| 341 |
+
parser.add_argument("--check-fp-precipitation", action="store_true",
|
| 342 |
+
help="Diagnostic: compares real recent precipitation (summed over the last "
|
| 343 |
+
"ROUTING_HORIZON_COUNT real days of each example's own input window) "
|
| 344 |
+
"across false positives vs. true negatives. Checked a real, specific "
|
| 345 |
+
"hypothesis -- that water_balance_loss, with ET hardcoded to 0.0 and no "
|
| 346 |
+
"storage term, has no way to represent real infiltration/storage "
|
| 347 |
+
"absorbing part of a rain event, and may be pushing the model toward "
|
| 348 |
+
"over-predicting discharge whenever real precipitation is high. Real "
|
| 349 |
+
"result from actual testing: the difference was real but tiny (8.80mm "
|
| 350 |
+
"vs 8.67mm mean, identical medians) -- too small to explain the real "
|
| 351 |
+
"precision problem, and fp counts don't track precipitation the way "
|
| 352 |
+
"they'd need to for this to be the main driver. Kept as a real, useful "
|
| 353 |
+
"check for future runs, but this specific hypothesis is not well "
|
| 354 |
+
"supported by the evidence so far -- see --check-quantile-spread for the "
|
| 355 |
+
"hypothesis that superseded it.")
|
| 356 |
+
parser.add_argument("--check-quantile-spread", action="store_true",
|
| 357 |
+
help="Diagnostic: reports the real, un-standardized gap between the model's "
|
| 358 |
+
"predicted 0.95 and 0.5 (median) discharge quantiles, per horizon. Real "
|
| 359 |
+
"result from actual clean testing (single global --flag-quantile, no "
|
| 360 |
+
"per-horizon overrides -- an earlier run with overrides active was "
|
| 361 |
+
"confounded, since switching the flagging quantile itself at the same "
|
| 362 |
+
"horizon boundary being examined can't be cleanly separated from a real "
|
| 363 |
+
"model-behavior effect): the spread grows smoothly and continuously with "
|
| 364 |
+
"horizon (~1.3-1.5x per step throughout), but real false positives jump "
|
| 365 |
+
"sharply at one specific point (day 4->5: 74->314 in one real run) that "
|
| 366 |
+
"the smooth spread growth does NOT explain (day 4->5 spread only grew "
|
| 367 |
+
"~1.3x, same as every other step) -- this specific hypothesis is NOT well "
|
| 368 |
+
"supported by the real evidence. The real day 4->5 jump instead lines up "
|
| 369 |
+
"with day 5 being one of the few horizons with real ECMWF forecast "
|
| 370 |
+
"coverage while day 4 has none -- see --check-forecast-fp-correlation for "
|
| 371 |
+
"the hypothesis that superseded this one.")
|
| 372 |
+
parser.add_argument("--check-forecast-fp-correlation", action="store_true",
|
| 373 |
+
help="Diagnostic: for horizons with real ECMWF forecast coverage (currently "
|
| 374 |
+
"[1,2,3,5,7], from FORECAST_LEAD_TIMES), reports what fraction of real "
|
| 375 |
+
"false positives at that horizon had real (non-missing) forecast "
|
| 376 |
+
"precipitation available, vs. the real base rate of forecast availability "
|
| 377 |
+
"across every real example at that horizon. Real result from actual "
|
| 378 |
+
"testing: a real but weak correlation (ratios 1.06-1.15x, all below the "
|
| 379 |
+
"1.5x flag threshold) that does NOT single out day 5 specifically (day 1-3 "
|
| 380 |
+
"showed similar ratios) -- this specific hypothesis is NOT well supported "
|
| 381 |
+
"either. See --dump-fp-details for direct inspection of the real false "
|
| 382 |
+
"positives themselves, which superseded this and the two hypotheses "
|
| 383 |
+
"before it (--check-fp-precipitation, --check-quantile-spread).")
|
| 384 |
+
parser.add_argument("--dump-fp-details", type=int, default=None,
|
| 385 |
+
help="Exploratory, not hypothesis-testing: dumps every real false positive at "
|
| 386 |
+
"the given horizon (station, date, predicted value, real threshold, "
|
| 387 |
+
"margin, days to the nearest real event) to a CSV, plus a quick printed "
|
| 388 |
+
"summary -- for direct inspection after three real, specific mechanisms "
|
| 389 |
+
"(precipitation, quantile-spread, forecast-availability) were each tested "
|
| 390 |
+
"and none explained the sharp real fp jump at day 5. Pass the horizon to "
|
| 391 |
+
"dump, e.g. '--dump-fp-details 5'.")
|
| 392 |
+
parser.add_argument("--dump-fp-output", type=Path, default=None,
|
| 393 |
+
help="Defaults to <data-root>/fp_details_horizon_<h>.csv")
|
| 394 |
+
parser.add_argument("--plot-single-trace", type=str, default=None,
|
| 395 |
+
help="Exploratory: plots one real (anchor, station) example's predicted "
|
| 396 |
+
"discharge quantiles across all real horizons, with the station's real "
|
| 397 |
+
"threshold and ROUTING_HORIZON_COUNT's boundary both marked -- a genuinely "
|
| 398 |
+
"different check than the aggregate statistics tried so far (precipitation "
|
| 399 |
+
"correlation, quantile-spread growth, forecast availability, forecast date "
|
| 400 |
+
"alignment; none fully explained the real day-5 false-positive pattern). A "
|
| 401 |
+
"visual discontinuity right at the physics-loss boundary would be "
|
| 402 |
+
"immediately obvious here in a way an aggregate number isn't. Format: "
|
| 403 |
+
"'station_code=anchor_date', e.g. 'H605641201=2026-01-01'.")
|
| 404 |
+
parser.add_argument("--plot-stations", type=str, default=None,
|
| 405 |
+
help="Comma-separated real station codes, e.g. 'H605641201,H404021101' -- for "
|
| 406 |
+
"each, plots real observed discharge, the model's real predicted median + "
|
| 407 |
+
"0.95 quantile, and a real per-station naive baseline (that station's own "
|
| 408 |
+
"training-period empirical median/0.95, not the global naive baseline used "
|
| 409 |
+
"for the overall skill comparison -- a station-specific comparison is more "
|
| 410 |
+
"meaningful here), all against real test-period dates, at one fixed "
|
| 411 |
+
"horizon (--plot-stations-horizon).")
|
| 412 |
+
parser.add_argument("--plot-stations-horizon", type=int, default=1,
|
| 413 |
+
help="Which real horizon to plot station time series at -- defaults to 1 (the "
|
| 414 |
+
"horizon with the strongest real, established skill in this project).")
|
| 415 |
+
args = parser.parse_args()
|
| 416 |
+
|
| 417 |
+
model_path = args.model_path or (args.data_root / "spatiotemporal_gnn" / "model.pt")
|
| 418 |
+
events_path = args.events_path or (args.data_root / "flood_events_discharge.csv")
|
| 419 |
+
|
| 420 |
+
events_df = pd.read_csv(events_path)
|
| 421 |
+
if events_df.empty:
|
| 422 |
+
print(f"{events_path} has no real events -- run identify_flood_events.py first.")
|
| 423 |
+
return
|
| 424 |
+
event_lookup = build_event_lookup(events_df)
|
| 425 |
+
station_thresholds = build_station_thresholds(events_df)
|
| 426 |
+
print(f"Loaded {len(events_df)} real event(s) across {events_df['station_code'].nunique()} station(s)")
|
| 427 |
+
|
| 428 |
+
# Rebuilding the exact same real data pipeline the model was
|
| 429 |
+
# trained on -- same subgraph, same node ordering, same
|
| 430 |
+
# standardization statistics. The saved weights are only meaningful
|
| 431 |
+
# against these exact conditions.
|
| 432 |
+
max_nodes_per_basin = args.max_nodes // len(BASIN_FILE_NAMES)
|
| 433 |
+
basin_data = []
|
| 434 |
+
for basin_id, file_key in BASIN_FILE_NAMES.items():
|
| 435 |
+
nodes_df, edges_df = load_basin_graph(args.data_root, file_key)
|
| 436 |
+
nodes_df, edges_df = build_collapsed_subgraph(nodes_df, edges_df, max_nodes=max_nodes_per_basin)
|
| 437 |
+
basin_data.append((nodes_df, edges_df, basin_id))
|
| 438 |
+
|
| 439 |
+
# Must match train_spatiotemporal_gnn.py's own shared_static_cols/
|
| 440 |
+
# gauge_static_cols exactly -- the saved checkpoint's input layer
|
| 441 |
+
# shape is fixed to whatever these were at training time. A mismatch
|
| 442 |
+
# here isn't cosmetic; it's the same real shape-mismatch bug class
|
| 443 |
+
# already caught twice before in this file (estimated_discharge,
|
| 444 |
+
# dt_tensors).
|
| 445 |
+
shared_static_cols = ["elevation_m", "catchment_area_km2", "idpr_value",
|
| 446 |
+
"avg_groundwater_level_m", "distance_to_nearest_cavity_km", "n_cavities_within_20km"]
|
| 447 |
+
gauge_static_cols: List[str] = []
|
| 448 |
+
combined = combine_basins(basin_data, shared_static_cols, gauge_static_cols)
|
| 449 |
+
station_codes = combined["station_codes"]
|
| 450 |
+
|
| 451 |
+
dynamic_tensors, target_tensor, waterlevel_target_tensor, dates = build_combined_dynamic_tensors(
|
| 452 |
+
combined, basin_data, args.data_root, ("2013-01-01", args.test_end)
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
# Mirrors train_spatiotemporal_gnn.py's main() exactly -- confirmed
|
| 456 |
+
# as a real, necessary fix, not an optional refinement: the saved
|
| 457 |
+
# checkpoint was trained with this channel present (added to
|
| 458 |
+
# main() several turns ago), and without replicating it here, the
|
| 459 |
+
# reconstructed model has 2 fewer input channels than the real
|
| 460 |
+
# checkpoint, causing a genuine shape-mismatch error on load. Same
|
| 461 |
+
# real reasoning as train_spatiotemporal_gnn.py's own version --
|
| 462 |
+
# see that file for the full explanation.
|
| 463 |
+
catchment_area_km2 = extract_catchment_area_km2(basin_data, combined)
|
| 464 |
+
n_real_catchment = int((~np.isnan(catchment_area_km2)).sum())
|
| 465 |
+
if "precipitation" in dynamic_tensors and n_real_catchment > 0:
|
| 466 |
+
try:
|
| 467 |
+
rolling_precip_sum = compute_rolling_precip_sum(dynamic_tensors["precipitation"], window_days=args.lookback_days)
|
| 468 |
+
specific_discharge_k = fit_specific_discharge_coefficient(
|
| 469 |
+
target_tensor, rolling_precip_sum, catchment_area_km2, dates, args.train_end,
|
| 470 |
+
)
|
| 471 |
+
estimated_discharge = estimate_discharge_from_precip(rolling_precip_sum, catchment_area_km2, specific_discharge_k)
|
| 472 |
+
dynamic_tensors["estimated_discharge"] = estimated_discharge
|
| 473 |
+
except ValueError:
|
| 474 |
+
pass # matches train_spatiotemporal_gnn.py's own graceful skip
|
| 475 |
+
|
| 476 |
+
dynamic_tensors, target_tensor, waterlevel_target_tensor, standardization_stats = standardize_dynamic_tensors(
|
| 477 |
+
dynamic_tensors, target_tensor, waterlevel_target_tensor, dates, args.train_end,
|
| 478 |
+
optional_vars=["precipitation", "estimated_discharge"],
|
| 479 |
+
)
|
| 480 |
+
discharge_mean, discharge_std = standardization_stats["discharge"]
|
| 481 |
+
|
| 482 |
+
# Mirrors train_spatiotemporal_gnn.py's own dt_tensors construction
|
| 483 |
+
# exactly -- the real checkpoint was trained with this channel
|
| 484 |
+
# present, so reconstructing the model without it here would cause
|
| 485 |
+
# the same real shape-mismatch error found once already for
|
| 486 |
+
# estimated_discharge.
|
| 487 |
+
dt_tensors = {
|
| 488 |
+
var: compress_days_since_last_real(compute_days_since_last_real(tensor))
|
| 489 |
+
for var, tensor in dynamic_tensors.items()
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
# For the real precipitation/false-positive correlation check
|
| 493 |
+
# (--check-fp-precipitation): the real channel index and real
|
| 494 |
+
# un-standardization stats, captured here since var_names'
|
| 495 |
+
# insertion order (and channels_per_var=3, matching the real dt
|
| 496 |
+
# channel this project added) has to match prepare_graph_training_
|
| 497 |
+
# windows' own real channel layout exactly, or this would silently
|
| 498 |
+
# read the wrong channel entirely.
|
| 499 |
+
var_names_for_channels = list(dynamic_tensors.keys())
|
| 500 |
+
has_real_precip = "precipitation" in var_names_for_channels
|
| 501 |
+
if has_real_precip:
|
| 502 |
+
precip_value_idx, _ = get_dynamic_channel_index(var_names_for_channels, "precipitation", channels_per_var=3)
|
| 503 |
+
precip_mean, precip_std = standardization_stats["precipitation"]
|
| 504 |
+
|
| 505 |
+
X, Y, Y_mask, anchor_dates = prepare_graph_training_windows(
|
| 506 |
+
combined["x_shared_static"], dynamic_tensors, target_tensor, dates, args.lookback_days, HORIZONS,
|
| 507 |
+
dt_tensors=dt_tensors,
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
# Held-out test split only -- evaluating against data the model saw
|
| 511 |
+
# during training would overstate real skill, same principle as
|
| 512 |
+
# every other evaluation in this project.
|
| 513 |
+
anchor_dates_arr = pd.DatetimeIndex(anchor_dates)
|
| 514 |
+
test_mask = anchor_dates_arr > pd.Timestamp(args.val_end)
|
| 515 |
+
X_test, Y_test, anchor_dates_test = X[test_mask], Y[test_mask], [d for d, keep in zip(anchor_dates, test_mask) if keep]
|
| 516 |
+
print(f"Evaluating on {len(X_test)} real held-out test example(s)")
|
| 517 |
+
|
| 518 |
+
# Real forecasted precipitation for the test examples -- mirrors
|
| 519 |
+
# train_spatiotemporal_gnn.py's own wiring exactly, so this
|
| 520 |
+
# evaluation reflects the same inputs the model was actually
|
| 521 |
+
# trained with, not a stripped-down version of them.
|
| 522 |
+
forecast_csv_path = args.data_root / "previous_runs_forecast.csv"
|
| 523 |
+
if forecast_csv_path.exists():
|
| 524 |
+
nodes_df_combined = pd.DataFrame({"station_code": station_codes})
|
| 525 |
+
forecast_precip_test, forecast_missing_test = build_forecast_tensor(
|
| 526 |
+
nodes_df_combined, forecast_csv_path, anchor_dates_test, HORIZONS,
|
| 527 |
+
)
|
| 528 |
+
n_real_forecast = int((forecast_missing_test == 0.0).sum())
|
| 529 |
+
print(f"Real forecast precipitation available for {n_real_forecast} test (example, node, horizon) value(s)")
|
| 530 |
+
else:
|
| 531 |
+
forecast_precip_test = np.zeros((len(X_test), combined["n_nodes"], len(HORIZONS)), dtype=np.float32)
|
| 532 |
+
forecast_missing_test = np.ones((len(X_test), combined["n_nodes"], len(HORIZONS)), dtype=np.float32)
|
| 533 |
+
print("No real forecast archive found -- evaluating without forecast precipitation")
|
| 534 |
+
|
| 535 |
+
x_shared_static_std, _, shared_static_flagged = standardize_static_features(
|
| 536 |
+
combined["x_shared_static"], shared_static_cols,
|
| 537 |
+
)
|
| 538 |
+
x_gauge_static_std, _, gauge_static_flagged = standardize_static_features(
|
| 539 |
+
combined["x_gauge_static"], gauge_static_cols,
|
| 540 |
+
)
|
| 541 |
+
print(f"Static feature standardization: shared missingness flags for {shared_static_flagged or 'none'}, "
|
| 542 |
+
f"gauge missingness flags for {gauge_static_flagged or 'none'}")
|
| 543 |
+
x_shared_static_t = torch.tensor(x_shared_static_std, dtype=torch.float32)
|
| 544 |
+
x_gauge_static_t = torch.tensor(x_gauge_static_std, dtype=torch.float32)
|
| 545 |
+
is_gauged_t = torch.tensor(combined["is_gauged"], dtype=torch.bool)
|
| 546 |
+
edge_index_t = torch.tensor(combined["edge_index"], dtype=torch.long)
|
| 547 |
+
edge_attr_t = torch.tensor(combined["edge_attr"], dtype=torch.float32)
|
| 548 |
+
basin_id_t = torch.tensor(combined["basin_id"], dtype=torch.long)
|
| 549 |
+
horizons_t = torch.tensor(HORIZONS, dtype=torch.float32)
|
| 550 |
+
|
| 551 |
+
model = SpatiotemporalGNN(
|
| 552 |
+
shared_static_dim=x_shared_static_t.shape[1], gauge_static_dim=x_gauge_static_t.shape[1],
|
| 553 |
+
gauge_dynamic_dim=X.shape[-1], edge_dim=edge_attr_t.shape[1],
|
| 554 |
+
n_basins=len(BASIN_FILE_NAMES), n_targets=2, quantiles=QUANTILES,
|
| 555 |
+
)
|
| 556 |
+
model.load_state_dict(torch.load(model_path, map_location="cpu"))
|
| 557 |
+
model.eval()
|
| 558 |
+
print(f"Loaded trained model from {model_path}")
|
| 559 |
+
|
| 560 |
+
if args.flag_quantile not in QUANTILES:
|
| 561 |
+
print(f"--flag-quantile {args.flag_quantile} isn't one of the model's real predicted quantiles "
|
| 562 |
+
f"{QUANTILES} -- pick one of those.")
|
| 563 |
+
return
|
| 564 |
+
default_quantile_idx = QUANTILES.index(args.flag_quantile)
|
| 565 |
+
|
| 566 |
+
# Per-horizon quantile_idx map -- every horizon defaults to
|
| 567 |
+
# --flag-quantile's index, then real per-horizon overrides replace
|
| 568 |
+
# specific entries. Built as an explicit {horizon: idx} dict rather
|
| 569 |
+
# than a single shared value, since real testing in this project
|
| 570 |
+
# showed the right operating point genuinely differs by horizon
|
| 571 |
+
# (see --flag-quantile-overrides' own help text for the real numbers).
|
| 572 |
+
quantile_idx_by_horizon = {h: default_quantile_idx for h in HORIZONS}
|
| 573 |
+
if args.flag_quantile_overrides:
|
| 574 |
+
for pair in args.flag_quantile_overrides.split(","):
|
| 575 |
+
pair = pair.strip()
|
| 576 |
+
if not pair:
|
| 577 |
+
continue
|
| 578 |
+
try:
|
| 579 |
+
h_str, q_str = pair.split("=")
|
| 580 |
+
h_override, q_override = int(h_str), float(q_str)
|
| 581 |
+
except ValueError:
|
| 582 |
+
print(f"Couldn't parse override '{pair}' -- expected format 'horizon=quantile', e.g. '5=0.9'.")
|
| 583 |
+
return
|
| 584 |
+
if h_override not in HORIZONS:
|
| 585 |
+
print(f"Override horizon {h_override} isn't one of this model's real horizons {HORIZONS}.")
|
| 586 |
+
return
|
| 587 |
+
if q_override not in QUANTILES:
|
| 588 |
+
print(f"Override quantile {q_override} (for horizon {h_override}) isn't one of the "
|
| 589 |
+
f"model's real predicted quantiles {QUANTILES} -- pick one of those.")
|
| 590 |
+
return
|
| 591 |
+
quantile_idx_by_horizon[h_override] = QUANTILES.index(q_override)
|
| 592 |
+
|
| 593 |
+
print(f"Flagging a real event when the model's predicted quantile exceeds that station's real "
|
| 594 |
+
f"threshold -- not the median, since flood risk is a tail question. Per-horizon quantile: "
|
| 595 |
+
f"{ {h: QUANTILES[idx] for h, idx in quantile_idx_by_horizon.items()} }")
|
| 596 |
+
|
| 597 |
+
# Confusion matrix per horizon: {horizon: {"tp":.., "fp":.., "fn":.., "tn":..}}
|
| 598 |
+
confusion = {h: {"tp": 0, "fp": 0, "fn": 0, "tn": 0} for h in HORIZONS}
|
| 599 |
+
fp_precip_values: List[float] = []
|
| 600 |
+
tn_precip_values: List[float] = []
|
| 601 |
+
# Real (0.95 - median) discharge spread, per horizon, across every
|
| 602 |
+
# real example and node regardless of tp/fp/fn/tn classification --
|
| 603 |
+
# unlike the precipitation check, this doesn't depend on the
|
| 604 |
+
# flagging outcome, so every real prediction contributes.
|
| 605 |
+
spread_by_horizon: Dict[int, List[float]] = {h: [] for h in HORIZONS}
|
| 606 |
+
if args.check_quantile_spread:
|
| 607 |
+
if 0.5 not in QUANTILES or 0.95 not in QUANTILES:
|
| 608 |
+
print("--check-quantile-spread needs both 0.5 and 0.95 in this model's real predicted "
|
| 609 |
+
f"quantiles {QUANTILES} -- neither is missing normally, but checked defensively.")
|
| 610 |
+
args.check_quantile_spread = False
|
| 611 |
+
else:
|
| 612 |
+
median_idx, q95_idx = QUANTILES.index(0.5), QUANTILES.index(0.95)
|
| 613 |
+
|
| 614 |
+
requested_stations: List[str] = []
|
| 615 |
+
station_timeseries_data: Dict[str, Dict[str, list]] = {}
|
| 616 |
+
if args.plot_stations:
|
| 617 |
+
if 0.5 not in QUANTILES or 0.95 not in QUANTILES:
|
| 618 |
+
print("--plot-stations needs both 0.5 and 0.95 in this model's real predicted quantiles "
|
| 619 |
+
f"{QUANTILES} -- neither is missing normally, but checked defensively.")
|
| 620 |
+
elif args.plot_stations_horizon not in HORIZONS:
|
| 621 |
+
print(f"--plot-stations-horizon {args.plot_stations_horizon} isn't one of this model's "
|
| 622 |
+
f"real horizons {HORIZONS}.")
|
| 623 |
+
else:
|
| 624 |
+
requested_stations = [s.strip() for s in args.plot_stations.split(",") if s.strip()]
|
| 625 |
+
unknown = [s for s in requested_stations if s not in station_codes]
|
| 626 |
+
if unknown:
|
| 627 |
+
print(f"Station(s) {unknown} aren't real station codes in this run -- dropping them.")
|
| 628 |
+
requested_stations = [s for s in requested_stations if s in station_codes]
|
| 629 |
+
median_idx_ts, q95_idx_ts = QUANTILES.index(0.5), QUANTILES.index(0.95)
|
| 630 |
+
plot_horizon_idx = HORIZONS.index(args.plot_stations_horizon)
|
| 631 |
+
station_timeseries_data = {
|
| 632 |
+
code: {"dates": [], "observed": [], "median": [], "q95": []} for code in requested_stations
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
# {horizon: {"fp_with_forecast": n, "fp_without_forecast": n,
|
| 636 |
+
# "all_with_forecast": n, "all_without_forecast": n}} --
|
| 637 |
+
# only meaningful for horizons in FORECAST_LEAD_TIMES, which is
|
| 638 |
+
# itself a real, direct subset of HORIZONS this project actually
|
| 639 |
+
# has real forecast coverage for.
|
| 640 |
+
forecast_fp_stats: Dict[int, Dict[str, int]] = {
|
| 641 |
+
h: {"fp_with_forecast": 0, "fp_without_forecast": 0, "all_with_forecast": 0, "all_without_forecast": 0}
|
| 642 |
+
for h in HORIZONS if h in FORECAST_LEAD_TIMES
|
| 643 |
+
}
|
| 644 |
+
fp_details: List[dict] = []
|
| 645 |
+
if args.dump_fp_details is not None and args.dump_fp_details not in HORIZONS:
|
| 646 |
+
print(f"--dump-fp-details {args.dump_fp_details} isn't one of this model's real horizons {HORIZONS}.")
|
| 647 |
+
return
|
| 648 |
+
|
| 649 |
+
with torch.no_grad():
|
| 650 |
+
for i in range(len(X_test)):
|
| 651 |
+
x_dynamic_seq = torch.tensor(X_test[i], dtype=torch.float32)
|
| 652 |
+
anchor = anchor_dates_test[i]
|
| 653 |
+
forecast_precip_i = torch.tensor(forecast_precip_test[i], dtype=torch.float32)
|
| 654 |
+
forecast_missing_i = torch.tensor(forecast_missing_test[i], dtype=torch.float32)
|
| 655 |
+
pred = model(x_shared_static_t, x_gauge_static_t, x_dynamic_seq, is_gauged_t,
|
| 656 |
+
edge_index_t, edge_attr_t, basin_id_t, horizons_t,
|
| 657 |
+
forecast_precip_i, forecast_missing_i)
|
| 658 |
+
# [n_nodes, n_horizons, n_quantiles] -- every predicted
|
| 659 |
+
# quantile, not just one: which specific quantile counts as
|
| 660 |
+
# "the flood flag" now genuinely varies by horizon (see
|
| 661 |
+
# quantile_idx_by_horizon), so the selection has to happen
|
| 662 |
+
# per-horizon below, not once for the whole tensor.
|
| 663 |
+
Q_pred_all_quantiles_standardized = pred[:, :, 0, :].numpy()
|
| 664 |
+
Q_pred_all_quantiles_real = Q_pred_all_quantiles_standardized * discharge_std + discharge_mean
|
| 665 |
+
|
| 666 |
+
if requested_stations:
|
| 667 |
+
# Real observed value at this specific (station, target
|
| 668 |
+
# date) -- Y_test is standardized the same way discharge
|
| 669 |
+
# predictions are, so the same real mean/std un-
|
| 670 |
+
# standardizes it correctly. NaN where no real
|
| 671 |
+
# observation exists, which plot_station_timeseries
|
| 672 |
+
# renders as a genuine gap, not a fabricated value.
|
| 673 |
+
target_date_ts = anchor + pd.Timedelta(days=args.plot_stations_horizon - 1)
|
| 674 |
+
Y_real_this_horizon = Y_test[i][:, plot_horizon_idx] * discharge_std + discharge_mean
|
| 675 |
+
for code in requested_stations:
|
| 676 |
+
node_idx_ts = station_codes.index(code)
|
| 677 |
+
station_timeseries_data[code]["dates"].append(target_date_ts)
|
| 678 |
+
station_timeseries_data[code]["observed"].append(float(Y_real_this_horizon[node_idx_ts]))
|
| 679 |
+
station_timeseries_data[code]["median"].append(
|
| 680 |
+
float(Q_pred_all_quantiles_real[node_idx_ts, plot_horizon_idx, median_idx_ts]))
|
| 681 |
+
station_timeseries_data[code]["q95"].append(
|
| 682 |
+
float(Q_pred_all_quantiles_real[node_idx_ts, plot_horizon_idx, q95_idx_ts]))
|
| 683 |
+
|
| 684 |
+
if args.check_quantile_spread:
|
| 685 |
+
# Real, un-standardized spread -- computed once per
|
| 686 |
+
# example here (all horizons, all nodes), not inside the
|
| 687 |
+
# per-horizon flagging loop below, since this doesn't
|
| 688 |
+
# depend on any station threshold or event label at all.
|
| 689 |
+
spread_real = Q_pred_all_quantiles_real[:, :, q95_idx] - Q_pred_all_quantiles_real[:, :, median_idx]
|
| 690 |
+
for h_idx, h in enumerate(HORIZONS):
|
| 691 |
+
spread_by_horizon[h].extend(spread_real[:, h_idx].tolist())
|
| 692 |
+
|
| 693 |
+
if args.check_fp_precipitation and has_real_precip:
|
| 694 |
+
# Real recent precipitation, per node, for THIS example's
|
| 695 |
+
# own input window -- one value per node, shared across
|
| 696 |
+
# every horizon below (the input window itself doesn't
|
| 697 |
+
# change with horizon, only the target date does), same
|
| 698 |
+
# ROUTING_HORIZON_COUNT-day real window water_balance_loss
|
| 699 |
+
# itself uses during training, not an arbitrarily chosen one.
|
| 700 |
+
recent_window = X_test[i][-ROUTING_HORIZON_COUNT:, :, precip_value_idx] # [days, n_nodes], standardized
|
| 701 |
+
# Missing days were filled with 0.0 (standardized) at
|
| 702 |
+
# window-build time -- treating that as a real 0mm
|
| 703 |
+
# reading would be wrong (0 standardized un-standardizes
|
| 704 |
+
# to precip_mean, not to a real zero), but for a
|
| 705 |
+
# diagnostic sum specifically, filled-missing days
|
| 706 |
+
# contribute precip_mean each, a real, if imperfect,
|
| 707 |
+
# neutral fill rather than silently skewing the sum -- an
|
| 708 |
+
# acceptable simplification for a diagnostic, not
|
| 709 |
+
# something that would be acceptable inside the loss
|
| 710 |
+
# itself.
|
| 711 |
+
recent_precip_sum_real_per_node = recent_window.sum(axis=0) * precip_std + ROUTING_HORIZON_COUNT * precip_mean
|
| 712 |
+
|
| 713 |
+
for h_idx, h in enumerate(HORIZONS):
|
| 714 |
+
target_date = anchor + pd.Timedelta(days=h - 1)
|
| 715 |
+
quantile_idx = quantile_idx_by_horizon[h]
|
| 716 |
+
Q_pred_real_this_horizon = Q_pred_all_quantiles_real[:, h_idx, quantile_idx]
|
| 717 |
+
track_forecast_fp = args.check_forecast_fp_correlation and h in FORECAST_LEAD_TIMES
|
| 718 |
+
for node_idx, station_code in enumerate(station_codes):
|
| 719 |
+
if station_code not in station_thresholds:
|
| 720 |
+
continue # no real threshold for this station at all -- nothing to evaluate
|
| 721 |
+
|
| 722 |
+
threshold = station_thresholds[station_code]
|
| 723 |
+
predicted_flag = Q_pred_real_this_horizon[node_idx] >= threshold
|
| 724 |
+
is_event = is_real_event_day(station_code, target_date, event_lookup) is not None
|
| 725 |
+
if track_forecast_fp:
|
| 726 |
+
has_real_forecast = forecast_missing_test[i][node_idx, h_idx] == 0.0
|
| 727 |
+
|
| 728 |
+
if is_event and predicted_flag:
|
| 729 |
+
confusion[h]["tp"] += 1
|
| 730 |
+
elif is_event and not predicted_flag:
|
| 731 |
+
confusion[h]["fn"] += 1
|
| 732 |
+
elif not is_event and predicted_flag:
|
| 733 |
+
confusion[h]["fp"] += 1
|
| 734 |
+
if args.check_fp_precipitation and has_real_precip:
|
| 735 |
+
fp_precip_values.append(float(recent_precip_sum_real_per_node[node_idx]))
|
| 736 |
+
if track_forecast_fp:
|
| 737 |
+
key = "fp_with_forecast" if has_real_forecast else "fp_without_forecast"
|
| 738 |
+
forecast_fp_stats[h][key] += 1
|
| 739 |
+
if args.dump_fp_details == h:
|
| 740 |
+
fp_details.append({
|
| 741 |
+
"station_code": station_code,
|
| 742 |
+
"target_date": target_date.date().isoformat(),
|
| 743 |
+
"predicted_value": float(Q_pred_real_this_horizon[node_idx]),
|
| 744 |
+
"threshold": float(threshold),
|
| 745 |
+
"margin": float(Q_pred_real_this_horizon[node_idx] - threshold),
|
| 746 |
+
"days_to_nearest_real_event": days_to_nearest_real_event(
|
| 747 |
+
station_code, target_date, event_lookup,
|
| 748 |
+
),
|
| 749 |
+
})
|
| 750 |
+
else:
|
| 751 |
+
confusion[h]["tn"] += 1
|
| 752 |
+
if args.check_fp_precipitation and has_real_precip:
|
| 753 |
+
tn_precip_values.append(float(recent_precip_sum_real_per_node[node_idx]))
|
| 754 |
+
|
| 755 |
+
if track_forecast_fp:
|
| 756 |
+
key = "all_with_forecast" if has_real_forecast else "all_without_forecast"
|
| 757 |
+
forecast_fp_stats[h][key] += 1
|
| 758 |
+
|
| 759 |
+
if requested_stations:
|
| 760 |
+
train_end_ts_ts = pd.Timestamp(args.train_end)
|
| 761 |
+
train_date_mask_ts = np.array([d <= train_end_ts_ts for d in dates])
|
| 762 |
+
print("\n" + "=" * 70)
|
| 763 |
+
print(f"Real station time-series plots, horizon={args.plot_stations_horizon}d")
|
| 764 |
+
print("=" * 70)
|
| 765 |
+
for code in requested_stations:
|
| 766 |
+
node_idx_ts = station_codes.index(code)
|
| 767 |
+
# Real, per-station naive baseline -- that station's own
|
| 768 |
+
# training-period empirical median/0.95 (real, un-
|
| 769 |
+
# standardized L/s), not the single global naive baseline
|
| 770 |
+
# used elsewhere for the overall skill percentage -- see
|
| 771 |
+
# plot_station_timeseries's own docstring for why a station-
|
| 772 |
+
# specific value is the more meaningful comparison here.
|
| 773 |
+
real_train_values = (target_tensor[node_idx_ts, train_date_mask_ts] * discharge_std
|
| 774 |
+
+ discharge_mean)
|
| 775 |
+
real_train_values = real_train_values[~np.isnan(real_train_values)]
|
| 776 |
+
if len(real_train_values) == 0:
|
| 777 |
+
print(f"Station {code}: no real training-period observations at all -- skipping.")
|
| 778 |
+
continue
|
| 779 |
+
naive_median = float(np.quantile(real_train_values, 0.5))
|
| 780 |
+
naive_q95 = float(np.quantile(real_train_values, 0.95))
|
| 781 |
+
threshold = station_thresholds.get(code, float("nan"))
|
| 782 |
+
|
| 783 |
+
plot_path = args.data_root / f"station_timeseries_{code}_h{args.plot_stations_horizon}.png"
|
| 784 |
+
plot_station_timeseries(
|
| 785 |
+
station_timeseries_data[code]["dates"], station_timeseries_data[code]["observed"],
|
| 786 |
+
station_timeseries_data[code]["median"], station_timeseries_data[code]["q95"],
|
| 787 |
+
naive_median, naive_q95, threshold, code, args.plot_stations_horizon, plot_path,
|
| 788 |
+
)
|
| 789 |
+
print(f"Station {code}: saved to {plot_path} (naive median={naive_median:.1f}, "
|
| 790 |
+
f"naive 0.95={naive_q95:.1f}, real threshold={threshold:.1f})")
|
| 791 |
+
|
| 792 |
+
if args.check_quantile_spread:
|
| 793 |
+
print("\n" + "=" * 70)
|
| 794 |
+
print("Real (0.95 - median) discharge quantile spread by horizon (real L/s)")
|
| 795 |
+
print("=" * 70)
|
| 796 |
+
fp_counts_by_horizon = {h: confusion[h]["fp"] for h in HORIZONS}
|
| 797 |
+
for h in HORIZONS:
|
| 798 |
+
values = spread_by_horizon[h]
|
| 799 |
+
if not values:
|
| 800 |
+
continue
|
| 801 |
+
arr = np.array(values)
|
| 802 |
+
print(f"horizon={h:>3}d: mean spread={arr.mean():>10.2f}, median spread={np.median(arr):>10.2f}, "
|
| 803 |
+
f"real fp count={fp_counts_by_horizon[h]:>5}")
|
| 804 |
+
# A real, direct check of the actual hypothesis: does the
|
| 805 |
+
# spread's own growth track where false positives explode
|
| 806 |
+
# (day 5 -> day 10 in the real run that motivated this check),
|
| 807 |
+
# rather than just eyeballing two printed columns.
|
| 808 |
+
short_horizons = [h for h in HORIZONS if h <= 5 and spread_by_horizon[h]]
|
| 809 |
+
long_horizons = [h for h in HORIZONS if h > 5 and spread_by_horizon[h]]
|
| 810 |
+
if short_horizons and long_horizons:
|
| 811 |
+
short_mean_spread = np.mean([np.mean(spread_by_horizon[h]) for h in short_horizons])
|
| 812 |
+
long_mean_spread = np.mean([np.mean(spread_by_horizon[h]) for h in long_horizons])
|
| 813 |
+
ratio = long_mean_spread / short_mean_spread if short_mean_spread > 0 else float("inf")
|
| 814 |
+
print(f"\nMean spread, horizons <=5d: {short_mean_spread:.2f}; horizons >5d: {long_mean_spread:.2f} "
|
| 815 |
+
f"(ratio: {ratio:.2f}x)")
|
| 816 |
+
if ratio > 2.0:
|
| 817 |
+
print(f"The real quantile spread grows substantially ({ratio:.2f}x) beyond day 5 -- "
|
| 818 |
+
f"consistent with (not proof of) genuine hedging/calibration widening being the "
|
| 819 |
+
f"real driver of the false-positive explosion at longer horizons, rather than a "
|
| 820 |
+
f"precipitation-specific mechanism.")
|
| 821 |
+
else:
|
| 822 |
+
print(f"The real quantile spread does NOT grow substantially beyond day 5 ({ratio:.2f}x) "
|
| 823 |
+
f"-- this specific hypothesis isn't well supported either; the real explanation "
|
| 824 |
+
f"for the false-positive jump likely needs further investigation.")
|
| 825 |
+
|
| 826 |
+
if args.plot_single_trace:
|
| 827 |
+
try:
|
| 828 |
+
station_code_req, anchor_str = args.plot_single_trace.split("=")
|
| 829 |
+
except ValueError:
|
| 830 |
+
print(f"--plot-single-trace '{args.plot_single_trace}' isn't in the expected "
|
| 831 |
+
f"'station_code=anchor_date' format.")
|
| 832 |
+
return
|
| 833 |
+
if station_code_req not in station_codes:
|
| 834 |
+
print(f"Station {station_code_req} isn't one of this run's real station codes.")
|
| 835 |
+
return
|
| 836 |
+
node_idx_req = station_codes.index(station_code_req)
|
| 837 |
+
anchor_req = pd.Timestamp(anchor_str)
|
| 838 |
+
matches = [i for i, a in enumerate(anchor_dates_test) if pd.Timestamp(a) == anchor_req]
|
| 839 |
+
if not matches:
|
| 840 |
+
print(f"Anchor date {anchor_str} isn't one of this run's real test anchor dates.")
|
| 841 |
+
return
|
| 842 |
+
i = matches[0]
|
| 843 |
+
|
| 844 |
+
with torch.no_grad():
|
| 845 |
+
x_dynamic_seq = torch.tensor(X_test[i], dtype=torch.float32)
|
| 846 |
+
forecast_precip_i = torch.tensor(forecast_precip_test[i], dtype=torch.float32)
|
| 847 |
+
forecast_missing_i = torch.tensor(forecast_missing_test[i], dtype=torch.float32)
|
| 848 |
+
pred = model(x_shared_static_t, x_gauge_static_t, x_dynamic_seq, is_gauged_t,
|
| 849 |
+
edge_index_t, edge_attr_t, basin_id_t, horizons_t,
|
| 850 |
+
forecast_precip_i, forecast_missing_i)
|
| 851 |
+
quantile_values_standardized = pred[node_idx_req, :, 0, :].numpy() # [n_horizons, n_quantiles]
|
| 852 |
+
quantile_values_real = quantile_values_standardized * discharge_std + discharge_mean
|
| 853 |
+
|
| 854 |
+
threshold = station_thresholds.get(station_code_req, float("nan"))
|
| 855 |
+
trace_plot_path = args.data_root / f"single_trace_{station_code_req}_{anchor_str}.png"
|
| 856 |
+
plot_single_trace(HORIZONS, QUANTILES, quantile_values_real, threshold,
|
| 857 |
+
ROUTING_HORIZON_COUNT, station_code_req, anchor_str, trace_plot_path)
|
| 858 |
+
print(f"\nSaved single-trace plot to {trace_plot_path}")
|
| 859 |
+
print(f"Real predicted values (real L/s), station={station_code_req}, anchor={anchor_str}:")
|
| 860 |
+
for h_idx, h in enumerate(HORIZONS):
|
| 861 |
+
row = ", ".join(f"q{q}={quantile_values_real[h_idx, q_idx]:.1f}" for q_idx, q in enumerate(QUANTILES))
|
| 862 |
+
print(f" horizon={h:>3}d: {row}")
|
| 863 |
+
|
| 864 |
+
if args.dump_fp_details is not None:
|
| 865 |
+
h = args.dump_fp_details
|
| 866 |
+
print("\n" + "=" * 70)
|
| 867 |
+
print(f"Real false positive details at horizon={h}d (n={len(fp_details)})")
|
| 868 |
+
print("=" * 70)
|
| 869 |
+
if not fp_details:
|
| 870 |
+
print("No real false positives at this horizon -- nothing to dump.")
|
| 871 |
+
else:
|
| 872 |
+
fp_df = pd.DataFrame(fp_details)
|
| 873 |
+
output_path = args.dump_fp_output or (args.data_root / f"fp_details_horizon_{h}.csv")
|
| 874 |
+
fp_df.to_csv(output_path, index=False)
|
| 875 |
+
print(f"Saved to {output_path}")
|
| 876 |
+
|
| 877 |
+
print("\nReal false positive count by station:")
|
| 878 |
+
print(fp_df["station_code"].value_counts().to_string())
|
| 879 |
+
|
| 880 |
+
n_near_miss = int((fp_df["days_to_nearest_real_event"] <= 3).sum())
|
| 881 |
+
n_no_event_at_all = int(np.isinf(fp_df["days_to_nearest_real_event"]).sum())
|
| 882 |
+
print(f"\n{n_near_miss}/{len(fp_df)} real false positives are within 3 real days of an actual "
|
| 883 |
+
f"real event (near-misses, not random noise); {n_no_event_at_all}/{len(fp_df)} are at "
|
| 884 |
+
f"stations with no real event anywhere in the real record at all.")
|
| 885 |
+
print(f"\nReal date range of these false positives: "
|
| 886 |
+
f"{fp_df['target_date'].min()} to {fp_df['target_date'].max()}")
|
| 887 |
+
print(f"Real margin (predicted - threshold) stats: mean={fp_df['margin'].mean():.2f}, "
|
| 888 |
+
f"median={fp_df['margin'].median():.2f}, max={fp_df['margin'].max():.2f}")
|
| 889 |
+
|
| 890 |
+
if args.check_forecast_fp_correlation:
|
| 891 |
+
print("\n" + "=" * 70)
|
| 892 |
+
print("Real forecast availability: false positives vs. the real base rate, by horizon")
|
| 893 |
+
print("=" * 70)
|
| 894 |
+
if not forecast_fp_stats:
|
| 895 |
+
print(f"No requested horizons fall in FORECAST_LEAD_TIMES {FORECAST_LEAD_TIMES} -- nothing to check.")
|
| 896 |
+
for h, stats in forecast_fp_stats.items():
|
| 897 |
+
n_fp = stats["fp_with_forecast"] + stats["fp_without_forecast"]
|
| 898 |
+
n_all = stats["all_with_forecast"] + stats["all_without_forecast"]
|
| 899 |
+
if n_fp == 0 or n_all == 0:
|
| 900 |
+
print(f"horizon={h:>2}d: no real false positives or no real examples at all -- nothing to compare.")
|
| 901 |
+
continue
|
| 902 |
+
fp_forecast_rate = stats["fp_with_forecast"] / n_fp
|
| 903 |
+
base_forecast_rate = stats["all_with_forecast"] / n_all
|
| 904 |
+
print(f"horizon={h:>2}d: {fp_forecast_rate*100:.1f}% of real false positives had real forecast "
|
| 905 |
+
f"data available, vs. {base_forecast_rate*100:.1f}% base rate across all real examples "
|
| 906 |
+
f"(n_fp={n_fp}, n_all={n_all})")
|
| 907 |
+
# A real, direct verdict, not just printed numbers to eyeball --
|
| 908 |
+
# flags horizons where false positives are genuinely
|
| 909 |
+
# disproportionately drawn from forecast-available examples,
|
| 910 |
+
# not just nominally different by a small amount.
|
| 911 |
+
flagged_horizons = []
|
| 912 |
+
for h, stats in forecast_fp_stats.items():
|
| 913 |
+
n_fp = stats["fp_with_forecast"] + stats["fp_without_forecast"]
|
| 914 |
+
n_all = stats["all_with_forecast"] + stats["all_without_forecast"]
|
| 915 |
+
if n_fp == 0 or n_all == 0:
|
| 916 |
+
continue
|
| 917 |
+
fp_rate = stats["fp_with_forecast"] / n_fp
|
| 918 |
+
base_rate = stats["all_with_forecast"] / n_all
|
| 919 |
+
if base_rate > 0 and fp_rate / base_rate > 1.5:
|
| 920 |
+
flagged_horizons.append(h)
|
| 921 |
+
if flagged_horizons:
|
| 922 |
+
print(f"\nReal false positives are disproportionately drawn from forecast-available examples "
|
| 923 |
+
f"at horizon(s) {flagged_horizons} -- real, direct evidence the forecast-conditioning "
|
| 924 |
+
f"pathway itself is contributing to false positives there, not generic horizon-driven "
|
| 925 |
+
f"hedging alone.")
|
| 926 |
+
else:
|
| 927 |
+
print(f"\nNo real horizon shows false positives disproportionately drawn from forecast-"
|
| 928 |
+
f"available examples -- this specific hypothesis isn't well supported by this "
|
| 929 |
+
f"real evidence either.")
|
| 930 |
+
|
| 931 |
+
if args.check_fp_precipitation:
|
| 932 |
+
if not has_real_precip:
|
| 933 |
+
print("\n--check-fp-precipitation requested, but no real precipitation channel is present "
|
| 934 |
+
"in this run -- nothing to check.")
|
| 935 |
+
elif not fp_precip_values or not tn_precip_values:
|
| 936 |
+
print("\n--check-fp-precipitation requested, but there were no real false positives or no "
|
| 937 |
+
"real true negatives to compare -- nothing to check.")
|
| 938 |
+
else:
|
| 939 |
+
fp_arr, tn_arr = np.array(fp_precip_values), np.array(tn_precip_values)
|
| 940 |
+
print("\n" + "=" * 70)
|
| 941 |
+
print(f"Real recent precipitation (mm, summed over the last {ROUTING_HORIZON_COUNT} real "
|
| 942 |
+
f"days), false positives vs. true negatives")
|
| 943 |
+
print("=" * 70)
|
| 944 |
+
print(f"False positives (n={len(fp_arr)}): mean={fp_arr.mean():.2f}mm, median={np.median(fp_arr):.2f}mm")
|
| 945 |
+
print(f"True negatives (n={len(tn_arr)}): mean={tn_arr.mean():.2f}mm, median={np.median(tn_arr):.2f}mm")
|
| 946 |
+
if fp_arr.mean() > tn_arr.mean():
|
| 947 |
+
print(f"False positives show real, higher recent precipitation on average "
|
| 948 |
+
f"({fp_arr.mean():.2f}mm vs {tn_arr.mean():.2f}mm) -- consistent with (not proof "
|
| 949 |
+
f"of) water_balance_loss's ET=0/no-storage gap pushing the model to over-predict "
|
| 950 |
+
f"discharge whenever real precipitation is high.")
|
| 951 |
+
else:
|
| 952 |
+
print(f"False positives do NOT show higher recent precipitation than true negatives -- "
|
| 953 |
+
f"this specific hypothesis isn't supported by this real evidence; the real "
|
| 954 |
+
f"explanation for weak precision likely lies elsewhere.")
|
| 955 |
+
|
| 956 |
+
print("\n" + "=" * 70)
|
| 957 |
+
print("Flood-detection confusion matrix by lead time (real held-out test data)")
|
| 958 |
+
print("=" * 70)
|
| 959 |
+
rows = []
|
| 960 |
+
for h in HORIZONS:
|
| 961 |
+
c = confusion[h]
|
| 962 |
+
precision = c["tp"] / (c["tp"] + c["fp"]) if (c["tp"] + c["fp"]) > 0 else float("nan")
|
| 963 |
+
recall = c["tp"] / (c["tp"] + c["fn"]) if (c["tp"] + c["fn"]) > 0 else float("nan")
|
| 964 |
+
rows.append({"horizon_days": h, "tp": c["tp"], "fp": c["fp"], "fn": c["fn"], "tn": c["tn"],
|
| 965 |
+
"precision": precision, "recall": recall})
|
| 966 |
+
result_df = pd.DataFrame(rows)
|
| 967 |
+
print(result_df.to_string(index=False))
|
| 968 |
+
|
| 969 |
+
output_path = args.data_root / "flood_detection_evaluation.csv"
|
| 970 |
+
result_df.to_csv(output_path, index=False)
|
| 971 |
+
print(f"\nSaved to {output_path}")
|
| 972 |
+
|
| 973 |
+
try:
|
| 974 |
+
confusion_plot_path = args.data_root / "flood_detection_confusion_matrices.png"
|
| 975 |
+
plot_confusion_matrices_by_horizon(confusion, HORIZONS, confusion_plot_path)
|
| 976 |
+
print(f"Saved confusion matrix grid to {confusion_plot_path}")
|
| 977 |
+
|
| 978 |
+
pr_plot_path = args.data_root / "flood_detection_precision_recall.png"
|
| 979 |
+
plot_precision_recall_by_horizon(result_df, pr_plot_path)
|
| 980 |
+
print(f"Saved precision/recall-vs-horizon plot to {pr_plot_path}")
|
| 981 |
+
except Exception as e:
|
| 982 |
+
print(f" [warning] failed to save evaluation plots: {e}")
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
if __name__ == "__main__":
|
| 986 |
+
main()
|
scripts/evaluation/identify_flood_events.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Identifies real historical high-flow events from discharge and
|
| 3 |
+
water-level records, station by station. This is the ground truth
|
| 4 |
+
against which any flood-prediction claim eventually has to be checked
|
| 5 |
+
-- "beats a naive mean-baseline" and "would have flagged the real
|
| 6 |
+
floods that actually happened" are different questions, and only the
|
| 7 |
+
second one matters for this project's actual purpose.
|
| 8 |
+
|
| 9 |
+
Threshold: each station's own historical 95th/99th percentile, not a
|
| 10 |
+
shared absolute value across stations. These rivers have very
|
| 11 |
+
different scales (median discharge ranges from under 1,000 to over
|
| 12 |
+
9,000 L/s across real stations in this project), so a fixed absolute
|
| 13 |
+
threshold would flag "high flow" at a small tributary while missing
|
| 14 |
+
real extremes at a larger one. A station-relative threshold puts every
|
| 15 |
+
station's real flood-scale events on the same footing.
|
| 16 |
+
|
| 17 |
+
Events, not days: a real flood spans several consecutive days above
|
| 18 |
+
threshold, and counting each day as a separate "event" would both
|
| 19 |
+
inflate the count and obscure how long real high-flow periods actually
|
| 20 |
+
last -- duration matters for anything downstream (evacuation windows,
|
| 21 |
+
infrastructure impact), not just peak magnitude.
|
| 22 |
+
|
| 23 |
+
Usage:
|
| 24 |
+
python -m scripts.identify_flood_events --data-root datasets
|
| 25 |
+
"""
|
| 26 |
+
import argparse
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import pandas as pd
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
from src.graph.dynamic_features import build_discharge_timeseries, build_waterlevel_timeseries
|
| 34 |
+
except ImportError:
|
| 35 |
+
import sys
|
| 36 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 37 |
+
from src.graph.dynamic_features import build_discharge_timeseries, build_waterlevel_timeseries
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def find_events(series: pd.Series, threshold: float, min_duration_days: int = 1) -> pd.DataFrame:
|
| 41 |
+
"""
|
| 42 |
+
Groups consecutive above-threshold days into discrete events for a
|
| 43 |
+
single station's real time series.
|
| 44 |
+
|
| 45 |
+
A gap of even one day below threshold ends the current event --
|
| 46 |
+
real recession rarely means the flow dropped and immediately rose
|
| 47 |
+
again, so treating a brief dip as "still the same event" would
|
| 48 |
+
understate how variable real hydrographs actually are.
|
| 49 |
+
|
| 50 |
+
Returns one row per event: start date, end date, duration, peak
|
| 51 |
+
value, peak date. Events shorter than min_duration_days are
|
| 52 |
+
dropped -- a single anomalous reading (a possible data error, or a
|
| 53 |
+
genuinely brief spike) is a different thing from a sustained high-
|
| 54 |
+
flow event, and conflating them would dilute what "event" means for
|
| 55 |
+
later evaluation.
|
| 56 |
+
"""
|
| 57 |
+
above = series >= threshold
|
| 58 |
+
if not above.any():
|
| 59 |
+
return pd.DataFrame(columns=["start_date", "end_date", "duration_days", "peak_value", "peak_date"])
|
| 60 |
+
|
| 61 |
+
# Assign a group id that increments each time the series drops
|
| 62 |
+
# below threshold -- consecutive True values share a group.
|
| 63 |
+
group_id = (~above).cumsum()
|
| 64 |
+
events = []
|
| 65 |
+
for gid, group in series[above].groupby(group_id[above]):
|
| 66 |
+
duration = len(group)
|
| 67 |
+
if duration < min_duration_days:
|
| 68 |
+
continue
|
| 69 |
+
events.append({
|
| 70 |
+
"start_date": group.index.min(),
|
| 71 |
+
"end_date": group.index.max(),
|
| 72 |
+
"duration_days": duration,
|
| 73 |
+
"peak_value": group.max(),
|
| 74 |
+
"peak_date": group.idxmax(),
|
| 75 |
+
})
|
| 76 |
+
return pd.DataFrame(events)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def identify_station_events(
|
| 80 |
+
wide: pd.DataFrame, percentile: float = 95.0, min_duration_days: int = 1,
|
| 81 |
+
) -> pd.DataFrame:
|
| 82 |
+
"""
|
| 83 |
+
Runs find_events for every station in a [date x station_code] wide
|
| 84 |
+
table, using each station's own real historical percentile as its
|
| 85 |
+
threshold. Stations with too little real data to compute a
|
| 86 |
+
meaningful percentile (fewer than 30 real observations, an
|
| 87 |
+
arbitrary but reasonable floor -- a percentile from a handful of
|
| 88 |
+
readings isn't a real threshold) are skipped, not given a
|
| 89 |
+
threshold computed from noise.
|
| 90 |
+
"""
|
| 91 |
+
all_events = []
|
| 92 |
+
for station_code in wide.columns:
|
| 93 |
+
series = wide[station_code].dropna()
|
| 94 |
+
if len(series) < 30:
|
| 95 |
+
continue
|
| 96 |
+
threshold = np.percentile(series, percentile)
|
| 97 |
+
station_events = find_events(series, threshold, min_duration_days)
|
| 98 |
+
if station_events.empty:
|
| 99 |
+
continue
|
| 100 |
+
station_events["station_code"] = station_code
|
| 101 |
+
station_events["threshold"] = threshold
|
| 102 |
+
station_events["threshold_percentile"] = percentile
|
| 103 |
+
all_events.append(station_events)
|
| 104 |
+
|
| 105 |
+
if not all_events:
|
| 106 |
+
return pd.DataFrame(columns=["station_code", "start_date", "end_date", "duration_days",
|
| 107 |
+
"peak_value", "peak_date", "threshold", "threshold_percentile"])
|
| 108 |
+
return pd.concat(all_events, ignore_index=True).sort_values(["station_code", "start_date"])
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def main() -> None:
|
| 112 |
+
parser = argparse.ArgumentParser(description="Identify real historical high-flow events")
|
| 113 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 114 |
+
parser.add_argument("--stations-path", type=Path, default=None)
|
| 115 |
+
parser.add_argument("--start-date", type=str, default="2013-01-01")
|
| 116 |
+
parser.add_argument("--end-date", type=str, default="2026-12-31")
|
| 117 |
+
parser.add_argument("--percentile", type=float, default=95.0,
|
| 118 |
+
help="Station-relative percentile defining the threshold for a high-flow day.")
|
| 119 |
+
parser.add_argument("--min-duration-days", type=int, default=1)
|
| 120 |
+
parser.add_argument("--output-dir", type=Path, default=None)
|
| 121 |
+
args = parser.parse_args()
|
| 122 |
+
|
| 123 |
+
stations_path = args.stations_path or (args.data_root / "station_elevations.csv")
|
| 124 |
+
nodes_df = pd.read_csv(stations_path)
|
| 125 |
+
|
| 126 |
+
date_range = (args.start_date, args.end_date)
|
| 127 |
+
output_dir = args.output_dir or args.data_root
|
| 128 |
+
|
| 129 |
+
discharge_wide = build_discharge_timeseries(nodes_df, args.data_root / "hydrometric", date_range)
|
| 130 |
+
discharge_events = identify_station_events(discharge_wide, args.percentile, args.min_duration_days)
|
| 131 |
+
print(f"Discharge: {len(discharge_events)} real event(s) across "
|
| 132 |
+
f"{discharge_events['station_code'].nunique() if not discharge_events.empty else 0} station(s)")
|
| 133 |
+
if not discharge_events.empty:
|
| 134 |
+
print(discharge_events.to_string(index=False))
|
| 135 |
+
discharge_out = output_dir / "flood_events_discharge.csv"
|
| 136 |
+
discharge_events.to_csv(discharge_out, index=False)
|
| 137 |
+
print(f"Saved to {discharge_out}\n")
|
| 138 |
+
|
| 139 |
+
waterlevel_wide = build_waterlevel_timeseries(nodes_df, args.data_root / "hydrometric", date_range)
|
| 140 |
+
waterlevel_events = identify_station_events(waterlevel_wide, args.percentile, args.min_duration_days)
|
| 141 |
+
print(f"Water level: {len(waterlevel_events)} real event(s) across "
|
| 142 |
+
f"{waterlevel_events['station_code'].nunique() if not waterlevel_events.empty else 0} station(s)")
|
| 143 |
+
if not waterlevel_events.empty:
|
| 144 |
+
print(waterlevel_events.to_string(index=False))
|
| 145 |
+
waterlevel_out = output_dir / "flood_events_waterlevel.csv"
|
| 146 |
+
waterlevel_events.to_csv(waterlevel_out, index=False)
|
| 147 |
+
print(f"Saved to {waterlevel_out}")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
main()
|
scripts/graph_building/__pycache__/enrich_reach_graph.cpython-311.pyc
ADDED
|
Binary file (6.94 kB). View file
|
|
|
scripts/graph_building/build_dynamic_tensors.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Builds genuine [n_nodes, T] dynamic tensors (discharge, groundwater,
|
| 3 |
+
climate) for a basin's reach graph and feeds them directly into
|
| 4 |
+
physics_losses.py's routing_consistency_loss -- the actual integration
|
| 5 |
+
point dynamic_features.py exists for. Without this script, that
|
| 6 |
+
function has real inputs it could consume but nothing actually
|
| 7 |
+
producing them.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python -m scripts.build_dynamic_tensors --data-root datasets --basin risle
|
| 11 |
+
"""
|
| 12 |
+
import argparse
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from src.graph.dynamic_features import (
|
| 20 |
+
build_discharge_timeseries, build_groundwater_timeseries,
|
| 21 |
+
build_climate_timeseries, assemble_dynamic_tensor,
|
| 22 |
+
)
|
| 23 |
+
from src.graph.physics_losses import build_routing_index, routing_consistency_loss
|
| 24 |
+
except ImportError:
|
| 25 |
+
import sys
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 27 |
+
from src.graph.dynamic_features import (
|
| 28 |
+
build_discharge_timeseries, build_groundwater_timeseries,
|
| 29 |
+
build_climate_timeseries, assemble_dynamic_tensor,
|
| 30 |
+
)
|
| 31 |
+
from src.graph.physics_losses import build_routing_index, routing_consistency_loss
|
| 32 |
+
|
| 33 |
+
BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_for_basin(data_root: Path, basin_id: int, file_key: str, date_range, skip_climate: bool) -> None:
|
| 37 |
+
graph_dir = data_root / "reach_graph"
|
| 38 |
+
nodes_path = graph_dir / f"{file_key}_nodes_enriched.csv"
|
| 39 |
+
edges_path = graph_dir / f"{file_key}_edges.csv"
|
| 40 |
+
if not nodes_path.exists() or not edges_path.exists():
|
| 41 |
+
print(f"{file_key}: missing enriched nodes/edges -- run build_reach_graphs.py + "
|
| 42 |
+
f"enrich_reach_graph.py first. Skipping.")
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
nodes_df = pd.read_csv(nodes_path)
|
| 46 |
+
edges_df = pd.read_csv(edges_path)
|
| 47 |
+
print(f"--- {file_key}: {len(nodes_df)} nodes, {len(edges_df)} edges ---")
|
| 48 |
+
|
| 49 |
+
discharge_wide = build_discharge_timeseries(nodes_df, data_root / "hydrometric", date_range)
|
| 50 |
+
Q, dates = assemble_dynamic_tensor(nodes_df, discharge_wide)
|
| 51 |
+
n_real = int((~np.isnan(Q)).sum())
|
| 52 |
+
print(f"discharge tensor: {Q.shape}, {n_real}/{Q.size} real (non-NaN) values "
|
| 53 |
+
f"({100*n_real/Q.size:.3f}% coverage -- expect this to be tiny, only real "
|
| 54 |
+
f"gauges with real observations ever have a value here)")
|
| 55 |
+
|
| 56 |
+
level_wide, depth_wide = build_groundwater_timeseries(nodes_df, data_root / "ades", date_range)
|
| 57 |
+
level_tensor, _ = assemble_dynamic_tensor(nodes_df, level_wide)
|
| 58 |
+
depth_tensor, _ = assemble_dynamic_tensor(nodes_df, depth_wide)
|
| 59 |
+
print(f"groundwater level tensor: {level_tensor.shape}, "
|
| 60 |
+
f"{int((~np.isnan(level_tensor)).sum())} real values")
|
| 61 |
+
|
| 62 |
+
climate_tensors = {}
|
| 63 |
+
if not skip_climate and (data_root / "safran").exists():
|
| 64 |
+
try:
|
| 65 |
+
climate_dict = build_climate_timeseries(nodes_df, data_root / "safran", date_range)
|
| 66 |
+
for var, wide in climate_dict.items():
|
| 67 |
+
tensor, _ = assemble_dynamic_tensor(nodes_df, wide)
|
| 68 |
+
climate_tensors[var] = tensor
|
| 69 |
+
print(f"climate variables: {list(climate_tensors.keys())}")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
print(f"climate skipped (error: {e})")
|
| 72 |
+
|
| 73 |
+
out_dir = graph_dir / "dynamic"
|
| 74 |
+
out_dir.mkdir(exist_ok=True)
|
| 75 |
+
save_kwargs = {
|
| 76 |
+
"discharge": Q, "groundwater_level": level_tensor, "groundwater_depth": depth_tensor,
|
| 77 |
+
"dates": np.array([str(d) for d in dates]), "station_codes": nodes_df["station_code"].values,
|
| 78 |
+
}
|
| 79 |
+
save_kwargs.update({f"climate_{k}": v for k, v in climate_tensors.items()})
|
| 80 |
+
out_path = out_dir / f"{file_key}_dynamic.npz"
|
| 81 |
+
np.savez(out_path, **save_kwargs)
|
| 82 |
+
print(f"saved to {out_path}")
|
| 83 |
+
|
| 84 |
+
# The actual integration: routing_consistency_loss needs Q + routing_index together.
|
| 85 |
+
routing_index = build_routing_index(nodes_df, edges_df, timestep_hours=24.0)
|
| 86 |
+
print(f"routing_index: {len(routing_index)} edge(s) with a usable lag at this timestep")
|
| 87 |
+
|
| 88 |
+
loss = routing_consistency_loss(Q, routing_index)
|
| 89 |
+
print(f"routing_consistency_loss on the REAL discharge tensor: {loss}")
|
| 90 |
+
if np.isnan(loss):
|
| 91 |
+
print(" ^ NaN. This is exactly the thing worth checking before assuming this loss "
|
| 92 |
+
"is usable: real Q is almost entirely NaN (only real gauges with real "
|
| 93 |
+
"observations have values), and if routing_consistency_loss doesn't mask "
|
| 94 |
+
"NaN out of its residuals before averaging, one missing value anywhere "
|
| 95 |
+
"poisons the entire loss to NaN. See whether this fired.")
|
| 96 |
+
print()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def main() -> None:
|
| 100 |
+
parser = argparse.ArgumentParser(description="Build dynamic tensors and wire into physics_losses.py")
|
| 101 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 102 |
+
parser.add_argument("--basin", choices=["eure", "risle", "both"], default="both")
|
| 103 |
+
parser.add_argument("--start-date", type=str, default="2013-01-01")
|
| 104 |
+
parser.add_argument("--end-date", type=str, default="2026-12-31")
|
| 105 |
+
parser.add_argument("--skip-climate", action="store_true")
|
| 106 |
+
args = parser.parse_args()
|
| 107 |
+
|
| 108 |
+
date_range = (args.start_date, args.end_date)
|
| 109 |
+
basins = BASIN_FILE_NAMES.items() if args.basin == "both" else \
|
| 110 |
+
[(k, v) for k, v in BASIN_FILE_NAMES.items() if v == args.basin]
|
| 111 |
+
|
| 112 |
+
for basin_id, file_key in basins:
|
| 113 |
+
run_for_basin(args.data_root, basin_id, file_key, date_range, args.skip_climate)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
if __name__ == "__main__":
|
| 117 |
+
main()
|
scripts/graph_building/build_reach_graphs.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build the reach-based river network graph for both basins from real BD TOPO
|
| 3 |
+
data, and report on every step so problems are visible immediately instead
|
| 4 |
+
of surfacing later as a silent wrong answer downstream.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python -m scripts.build_reach_graphs --data-root datasets
|
| 8 |
+
"""
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from src.graph.build_reach_graph import (
|
| 18 |
+
check_flow_direction_coverage, load_troncons_for_basin, build_node_link_digraph,
|
| 19 |
+
best_component_for_stations, snap_gauges_to_reach_graph, insert_virtual_nodes,
|
| 20 |
+
build_reach_graph_tables, summarize_confluences_by_tributary, TRIBUTARY_NAMES, normalize_toponym,
|
| 21 |
+
)
|
| 22 |
+
from src.data.loaders.station_elevations import StationElevationsLoader
|
| 23 |
+
from src.data.river_graph import assign_basin_id
|
| 24 |
+
except ImportError:
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 26 |
+
from src.graph.build_reach_graph import (
|
| 27 |
+
check_flow_direction_coverage, load_troncons_for_basin, build_node_link_digraph,
|
| 28 |
+
best_component_for_stations, snap_gauges_to_reach_graph, insert_virtual_nodes,
|
| 29 |
+
build_reach_graph_tables, summarize_confluences_by_tributary, TRIBUTARY_NAMES, normalize_toponym,
|
| 30 |
+
)
|
| 31 |
+
from src.data.loaders.station_elevations import StationElevationsLoader
|
| 32 |
+
from src.data.river_graph import assign_basin_id
|
| 33 |
+
|
| 34 |
+
BASIN_NAMES = {0: "La Eure", 1: "La Risle"}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def run_for_basin(
|
| 38 |
+
troncon_geojson: dict,
|
| 39 |
+
stations_df: pd.DataFrame,
|
| 40 |
+
basin_id: int,
|
| 41 |
+
virtual_node_spacing_km: float,
|
| 42 |
+
anchor_radius_km: float,
|
| 43 |
+
component_search_radius_km: float,
|
| 44 |
+
) -> "tuple[pd.DataFrame, pd.DataFrame] | None":
|
| 45 |
+
name = BASIN_NAMES[basin_id]
|
| 46 |
+
print("=" * 70)
|
| 47 |
+
print(f"{name}")
|
| 48 |
+
print("=" * 70)
|
| 49 |
+
|
| 50 |
+
basin_stations = stations_df[stations_df["basin_id"] == basin_id].copy()
|
| 51 |
+
|
| 52 |
+
matched = load_troncons_for_basin(
|
| 53 |
+
troncon_geojson, basin_id=basin_id,
|
| 54 |
+
anchor_stations=basin_stations, max_distance_from_anchor_km=anchor_radius_km,
|
| 55 |
+
)
|
| 56 |
+
print(f"Matched {len(matched)} tronçons by name AND within {anchor_radius_km} km of a "
|
| 57 |
+
f"real gauge (name-only matching pulled in geographically unrelated tronçons "
|
| 58 |
+
f"in earlier runs -- see build_reach_graph.py's load_troncons_for_basin docstring)")
|
| 59 |
+
if not matched:
|
| 60 |
+
print(" Nothing matched -- try a larger --anchor-radius-km, or check "
|
| 61 |
+
"TRIBUTARY_NAMES against this file's actual toponym values.")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
print()
|
| 65 |
+
print("Flow direction field coverage (sens_de_l_ecoulement), all matched tronçons:")
|
| 66 |
+
coverage = check_flow_direction_coverage(matched)
|
| 67 |
+
for value, count in sorted(coverage.items(), key=lambda x: -x[1]):
|
| 68 |
+
print(f" {value!r}: {count}")
|
| 69 |
+
print(" If this doesn't show recognizable direction values, "
|
| 70 |
+
"build_node_link_digraph's sens_downstream_values/sens_upstream_values "
|
| 71 |
+
"need updating before its elevation fallback is the only thing working.")
|
| 72 |
+
|
| 73 |
+
print()
|
| 74 |
+
G, report = build_node_link_digraph(matched)
|
| 75 |
+
print(report)
|
| 76 |
+
|
| 77 |
+
n_known_tributaries = len(TRIBUTARY_NAMES.get(basin_id, [])) - 1 # exclude the main river itself
|
| 78 |
+
print(f" ({n_known_tributaries} known named tributaries for this basin -- confluence count "
|
| 79 |
+
f"should be in this neighborhood, not far beyond it, if a tributary joins the main "
|
| 80 |
+
f"river roughly once)")
|
| 81 |
+
tributary_summary = summarize_confluences_by_tributary(G)
|
| 82 |
+
if not tributary_summary.empty:
|
| 83 |
+
counts = tributary_summary["joining_river"].value_counts()
|
| 84 |
+
print(f" Confluences by joining river (post name-normalization): {dict(counts)}")
|
| 85 |
+
if len(counts) > n_known_tributaries + 3: # some slack for real sub-tributary branching
|
| 86 |
+
print(f" NOTE: more distinct joining-river names ({len(counts)}) than known "
|
| 87 |
+
f"tributaries ({n_known_tributaries}) -- worth checking whether some of "
|
| 88 |
+
f"these are actually the same river under yet another name variant "
|
| 89 |
+
f"normalize_toponym doesn't catch, or genuinely unlisted tributaries.")
|
| 90 |
+
|
| 91 |
+
if report.disconnected_components > 1:
|
| 92 |
+
print(f" {report.disconnected_components} component(s) found. Selecting by which "
|
| 93 |
+
f"one actually contains our real gauges, not by raw size -- the two aren't "
|
| 94 |
+
f"guaranteed to agree, and in earlier runs they didn't.")
|
| 95 |
+
G, gauge_counts = best_component_for_stations(G, basin_stations, search_radius_km=component_search_radius_km)
|
| 96 |
+
if len(gauge_counts) > 1:
|
| 97 |
+
sorted_counts = sorted(gauge_counts.values(), reverse=True)
|
| 98 |
+
print(f" Gauges captured per component: {sorted_counts[:5]}{'...' if len(sorted_counts) > 5 else ''}")
|
| 99 |
+
if sorted_counts[0] < len(basin_stations):
|
| 100 |
+
print(f" WARNING: the winning component only captures {sorted_counts[0]} of "
|
| 101 |
+
f"{len(basin_stations)} gauges -- this basin's real network may still be "
|
| 102 |
+
f"fragmented across more than one component even after the anchor filter.")
|
| 103 |
+
|
| 104 |
+
print()
|
| 105 |
+
print(f"Snapping {len(basin_stations)} real gauge(s) onto the reach graph...")
|
| 106 |
+
snapped = snap_gauges_to_reach_graph(G, basin_stations)
|
| 107 |
+
print(snapped[["station_code", "reach_u", "reach_v", "reach_fraction", "snap_distance_km"]]
|
| 108 |
+
.sort_values("snap_distance_km", ascending=False).to_string(index=False))
|
| 109 |
+
unsnapped = snapped["reach_u"].isna().sum()
|
| 110 |
+
if unsnapped:
|
| 111 |
+
print(f" WARNING: {unsnapped} station(s) got no nearest edge at all -- "
|
| 112 |
+
f"they're likely outside this basin's matched tronçon set.")
|
| 113 |
+
large_snap = (snapped["snap_distance_km"] > 2.0).sum()
|
| 114 |
+
if large_snap:
|
| 115 |
+
print(f" NOTE: {large_snap} station(s) snapped more than 2 km from any "
|
| 116 |
+
f"reach -- worth checking those aren't actually on an unmatched tributary.")
|
| 117 |
+
|
| 118 |
+
print()
|
| 119 |
+
G_virtual, n_inserted = insert_virtual_nodes(G, spacing_km=virtual_node_spacing_km)
|
| 120 |
+
print(f"Inserted {n_inserted} virtual node(s) at ~{virtual_node_spacing_km} km spacing "
|
| 121 |
+
f"({G.number_of_nodes()} real nodes -> {G_virtual.number_of_nodes()} total)")
|
| 122 |
+
|
| 123 |
+
print()
|
| 124 |
+
nodes_df, edges_df = build_reach_graph_tables(G_virtual, snapped, basin_id=basin_id)
|
| 125 |
+
n_confluences = int(nodes_df["is_confluence"].sum())
|
| 126 |
+
print(f"Final table: {len(nodes_df)} nodes ({nodes_df['is_gauged'].sum()} gauged, "
|
| 127 |
+
f"{n_confluences} confluences, {len(nodes_df) - nodes_df['is_gauged'].sum() - n_confluences} "
|
| 128 |
+
f"virtual/other), {len(edges_df)} edges")
|
| 129 |
+
|
| 130 |
+
return nodes_df, edges_df
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main() -> None:
|
| 134 |
+
parser = argparse.ArgumentParser(description="Build reach-based river network graphs")
|
| 135 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 136 |
+
parser.add_argument("--virtual-node-spacing-km", type=float, default=5.0)
|
| 137 |
+
parser.add_argument("--anchor-radius-km", type=float, default=20.0,
|
| 138 |
+
help="Max distance from a real gauge for a name-matched tronçon to be kept")
|
| 139 |
+
parser.add_argument("--component-search-radius-km", type=float, default=5.0,
|
| 140 |
+
help="How close a gauge must be to a component to count as 'in' it")
|
| 141 |
+
parser.add_argument("--output-dir", type=Path, default=None,
|
| 142 |
+
help="Defaults to <data-root>/reach_graph")
|
| 143 |
+
args = parser.parse_args()
|
| 144 |
+
|
| 145 |
+
troncon_path = args.data_root / "bdtopo_hydro" / "troncon_hydrographique.geojson"
|
| 146 |
+
if not troncon_path.exists():
|
| 147 |
+
print(f"Missing {troncon_path} -- run scripts/download_bdtopo_hydro.py first.")
|
| 148 |
+
sys.exit(1)
|
| 149 |
+
troncon_geojson = json.loads(troncon_path.read_text())
|
| 150 |
+
|
| 151 |
+
stations_df = StationElevationsLoader(data_path=args.data_root / "station_elevations.csv").load()
|
| 152 |
+
stations_df["basin_id"] = stations_df["station_code"].apply(assign_basin_id)
|
| 153 |
+
unmatched = stations_df["basin_id"].isna().sum()
|
| 154 |
+
if unmatched:
|
| 155 |
+
print(f"NOTE: {unmatched} station(s) matched no basin prefix and are excluded entirely.\n")
|
| 156 |
+
stations_df = stations_df.dropna(subset=["basin_id"])
|
| 157 |
+
stations_df["basin_id"] = stations_df["basin_id"].astype(int)
|
| 158 |
+
|
| 159 |
+
output_dir = args.output_dir or (args.data_root / "reach_graph")
|
| 160 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 161 |
+
|
| 162 |
+
all_nodes, all_edges = [], []
|
| 163 |
+
for basin_id, file_key in [(0, "eure"), (1, "risle")]:
|
| 164 |
+
result = run_for_basin(troncon_geojson, stations_df, basin_id, args.virtual_node_spacing_km,
|
| 165 |
+
args.anchor_radius_km, args.component_search_radius_km)
|
| 166 |
+
print()
|
| 167 |
+
if result is None:
|
| 168 |
+
continue
|
| 169 |
+
nodes_df, edges_df = result
|
| 170 |
+
nodes_df.to_csv(output_dir / f"{file_key}_nodes.csv", index=False)
|
| 171 |
+
edges_df.to_csv(output_dir / f"{file_key}_edges.csv", index=False)
|
| 172 |
+
print(f"Saved {file_key}_nodes.csv / {file_key}_edges.csv to {output_dir}")
|
| 173 |
+
print()
|
| 174 |
+
all_nodes.append(nodes_df)
|
| 175 |
+
all_edges.append(edges_df)
|
| 176 |
+
|
| 177 |
+
if all_nodes:
|
| 178 |
+
print("=" * 70)
|
| 179 |
+
print("SUMMARY")
|
| 180 |
+
print("=" * 70)
|
| 181 |
+
combined_nodes = pd.concat(all_nodes, ignore_index=True)
|
| 182 |
+
combined_edges = pd.concat(all_edges, ignore_index=True)
|
| 183 |
+
print(f"Total: {len(combined_nodes)} nodes, {len(combined_edges)} edges across both basins")
|
| 184 |
+
print(f"Gauged: {combined_nodes['is_gauged'].sum()}")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
if __name__ == "__main__":
|
| 188 |
+
main()
|
scripts/graph_building/compute_cumulative_catchment.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fills catchment_area_km2 for every reach graph node (confluences,
|
| 3 |
+
splits, rejoins, virtual infill -- not just the ~8 real gauges Hub'Eau
|
| 4 |
+
publishes it for), using BD TOPO's incremental catchment polygons
|
| 5 |
+
(bassin_versant_topographique.geojson).
|
| 6 |
+
|
| 7 |
+
ALGORITHM:
|
| 8 |
+
1. Spatially assign each incremental polygon to the edges it contains
|
| 9 |
+
(an edge's midpoint falling inside a polygon means that edge's
|
| 10 |
+
reach is the one that polygon's incremental drainage feeds).
|
| 11 |
+
2. For any node, cumulative catchment area = sum of the AREAS OF
|
| 12 |
+
DISTINCT POLYGONS among every edge upstream of it (walked via the
|
| 13 |
+
real directed graph, unbounded -- unlike the split/rejoin check's
|
| 14 |
+
15km search radius, catchment area needs the true full upstream
|
| 15 |
+
drainage, however far that extends). "Distinct" matters: many
|
| 16 |
+
edges (and the virtual nodes on them) share the same polygon, since
|
| 17 |
+
BD TOPO's confluence-to-confluence stretches are far coarser than
|
| 18 |
+
our node resolution -- summing per-edge would massively overcount.
|
| 19 |
+
|
| 20 |
+
CAVEAT: the polygon assignment step needs geopandas, which isn't
|
| 21 |
+
available in the environment this was developed in (no network access
|
| 22 |
+
to install it). The graph-traversal/area-summation logic (the actual
|
| 23 |
+
novel part) is tested with synthetic polygon assignments standing in
|
| 24 |
+
for a real spatial join -- but the spatial join itself has not been run
|
| 25 |
+
against real data. Please verify the polygon assignment step
|
| 26 |
+
specifically (e.g. spot-check a few edges against the map) before
|
| 27 |
+
trusting the final areas.
|
| 28 |
+
|
| 29 |
+
Usage:
|
| 30 |
+
python -m scripts.compute_cumulative_catchment --data-root datasets
|
| 31 |
+
"""
|
| 32 |
+
import argparse
|
| 33 |
+
import sys
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import networkx as nx
|
| 37 |
+
import pandas as pd
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def assign_edges_to_polygons(edges_df: pd.DataFrame, nodes_df: pd.DataFrame, polygons_path: Path) -> pd.Series:
|
| 41 |
+
"""
|
| 42 |
+
Spatial join: for each edge, which incremental catchment polygon
|
| 43 |
+
contains its midpoint. Returns a Series indexed like edges_df, with
|
| 44 |
+
the polygon's unique identifier (or NaN if no polygon contains it --
|
| 45 |
+
can happen near basin edges or bbox boundaries).
|
| 46 |
+
|
| 47 |
+
NEEDS geopandas -- not verified in the environment this was written
|
| 48 |
+
in, see module docstring.
|
| 49 |
+
"""
|
| 50 |
+
import geopandas as gpd
|
| 51 |
+
from shapely.geometry import Point
|
| 52 |
+
|
| 53 |
+
polygons = gpd.read_file(polygons_path)
|
| 54 |
+
if "cleabs" in polygons.columns:
|
| 55 |
+
poly_id_col = "cleabs"
|
| 56 |
+
else:
|
| 57 |
+
polygons = polygons.reset_index().rename(columns={"index": "_poly_idx"})
|
| 58 |
+
poly_id_col = "_poly_idx"
|
| 59 |
+
|
| 60 |
+
coord = nodes_df.set_index("station_code")[["latitude", "longitude"]]
|
| 61 |
+
midpoints = []
|
| 62 |
+
for _, e in edges_df.iterrows():
|
| 63 |
+
try:
|
| 64 |
+
src, tgt = coord.loc[e["source"]], coord.loc[e["target"]]
|
| 65 |
+
except KeyError:
|
| 66 |
+
midpoints.append(None)
|
| 67 |
+
continue
|
| 68 |
+
midpoints.append(Point((src["longitude"] + tgt["longitude"]) / 2,
|
| 69 |
+
(src["latitude"] + tgt["latitude"]) / 2))
|
| 70 |
+
|
| 71 |
+
edge_points = gpd.GeoDataFrame({"edge_idx": range(len(edges_df))}, geometry=midpoints, crs=polygons.crs)
|
| 72 |
+
joined = gpd.sjoin(edge_points.dropna(subset=["geometry"]), polygons[[poly_id_col, "geometry"]],
|
| 73 |
+
how="left", predicate="within")
|
| 74 |
+
joined = joined.drop_duplicates("edge_idx") # a midpoint exactly on a shared boundary could match >1 polygon
|
| 75 |
+
|
| 76 |
+
result = pd.Series(index=range(len(edges_df)), dtype=object)
|
| 77 |
+
result.loc[joined["edge_idx"]] = joined[poly_id_col].values
|
| 78 |
+
return result
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def compute_cumulative_catchment(
|
| 82 |
+
nodes_df: pd.DataFrame,
|
| 83 |
+
edges_df: pd.DataFrame,
|
| 84 |
+
edge_polygon_id: pd.Series,
|
| 85 |
+
polygon_areas: dict,
|
| 86 |
+
) -> pd.Series:
|
| 87 |
+
"""
|
| 88 |
+
For every node, sum the areas of distinct polygons among all edges
|
| 89 |
+
upstream of it (inclusive of the edge(s) arriving at the node
|
| 90 |
+
itself). Pure graph-traversal + arithmetic -- no geospatial
|
| 91 |
+
dependency, fully testable without geopandas (see the test in this
|
| 92 |
+
script's accompanying verification, or run this module's functions
|
| 93 |
+
directly against synthetic data).
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
nodes_df: reach graph nodes [station_code, ...]
|
| 97 |
+
edges_df: reach graph edges [source, target, ...], same row
|
| 98 |
+
order/index as edge_polygon_id.
|
| 99 |
+
edge_polygon_id: from assign_edges_to_polygons (or an equivalent
|
| 100 |
+
manually-built mapping for testing) -- polygon ID per edge,
|
| 101 |
+
aligned to edges_df's index.
|
| 102 |
+
polygon_areas: {polygon_id: area_km2}
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
Series indexed like nodes_df, cumulative catchment area in km2
|
| 106 |
+
(NaN for a node with no upstream edges assigned to any polygon
|
| 107 |
+
at all -- a true headwater with no BD TOPO catchment coverage,
|
| 108 |
+
distinct from zero).
|
| 109 |
+
"""
|
| 110 |
+
G = nx.DiGraph()
|
| 111 |
+
G.add_nodes_from(nodes_df["station_code"])
|
| 112 |
+
edge_to_poly = {}
|
| 113 |
+
for i, e in edges_df.iterrows():
|
| 114 |
+
G.add_edge(e["source"], e["target"])
|
| 115 |
+
poly = edge_polygon_id.get(i)
|
| 116 |
+
if pd.notna(poly):
|
| 117 |
+
edge_to_poly[(e["source"], e["target"])] = poly
|
| 118 |
+
|
| 119 |
+
results = {}
|
| 120 |
+
for node in nodes_df["station_code"]:
|
| 121 |
+
ancestors = nx.ancestors(G, node) | {node}
|
| 122 |
+
# every edge whose TARGET is in this node's ancestor-or-self set
|
| 123 |
+
# is an edge that feeds into this node's upstream drainage
|
| 124 |
+
polys_here = set()
|
| 125 |
+
for (u, v), poly in edge_to_poly.items():
|
| 126 |
+
if v in ancestors:
|
| 127 |
+
polys_here.add(poly)
|
| 128 |
+
if not polys_here:
|
| 129 |
+
results[node] = float("nan")
|
| 130 |
+
else:
|
| 131 |
+
results[node] = sum(polygon_areas.get(p, 0.0) for p in polys_here)
|
| 132 |
+
|
| 133 |
+
return pd.Series(results).reindex(nodes_df["station_code"])
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def run_for_basin(data_root: Path, basin_id: int, file_key: str) -> None:
|
| 137 |
+
graph_dir = data_root / "reach_graph"
|
| 138 |
+
enriched_path = graph_dir / f"{file_key}_nodes_enriched.csv"
|
| 139 |
+
edges_path = graph_dir / f"{file_key}_edges.csv"
|
| 140 |
+
polygons_path = data_root / "bdtopo_hydro" / "bassin_versant_topographique.geojson"
|
| 141 |
+
|
| 142 |
+
if not enriched_path.exists() or not edges_path.exists():
|
| 143 |
+
print(f"{file_key}: missing enriched nodes/edges, run build_reach_graphs.py + "
|
| 144 |
+
f"enrich_reach_graph.py first. Skipping.")
|
| 145 |
+
return
|
| 146 |
+
if not polygons_path.exists():
|
| 147 |
+
print(f"{file_key}: no {polygons_path} found -- run download_bdtopo_hydro.py first. Skipping.")
|
| 148 |
+
return
|
| 149 |
+
|
| 150 |
+
nodes_df = pd.read_csv(enriched_path)
|
| 151 |
+
edges_df = pd.read_csv(edges_path)
|
| 152 |
+
print(f"{file_key}: {len(nodes_df)} nodes, {len(edges_df)} edges")
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
import geopandas as gpd
|
| 156 |
+
except ImportError:
|
| 157 |
+
print(f"{file_key}: geopandas not installed -- cannot do the spatial join. Skipping.")
|
| 158 |
+
return
|
| 159 |
+
|
| 160 |
+
polygons = gpd.read_file(polygons_path)
|
| 161 |
+
poly_id_col = "cleabs" if "cleabs" in polygons.columns else "_poly_idx"
|
| 162 |
+
if poly_id_col == "_poly_idx":
|
| 163 |
+
polygons = polygons.reset_index().rename(columns={"index": poly_id_col})
|
| 164 |
+
polygons_metric = polygons.to_crs(epsg=2154)
|
| 165 |
+
polygon_areas = dict(zip(polygons[poly_id_col], polygons_metric.geometry.area / 1e6))
|
| 166 |
+
|
| 167 |
+
edge_polygon_id = assign_edges_to_polygons(edges_df, nodes_df, polygons_path)
|
| 168 |
+
n_assigned = edge_polygon_id.notna().sum()
|
| 169 |
+
print(f" {n_assigned}/{len(edges_df)} edges assigned to a polygon "
|
| 170 |
+
f"({len(edges_df) - n_assigned} unassigned -- near bbox/basin edges, expected)")
|
| 171 |
+
|
| 172 |
+
cumulative = compute_cumulative_catchment(nodes_df, edges_df, edge_polygon_id, polygon_areas)
|
| 173 |
+
nodes_df["cumulative_catchment_area_km2"] = cumulative.values
|
| 174 |
+
|
| 175 |
+
n_covered = nodes_df["cumulative_catchment_area_km2"].notna().sum()
|
| 176 |
+
print(f" cumulative_catchment_area_km2 coverage: {n_covered}/{len(nodes_df)} nodes "
|
| 177 |
+
f"(vs. {nodes_df['catchment_area_km2'].notna().sum()} from Hub'Eau's own reported values)")
|
| 178 |
+
|
| 179 |
+
# Cross-check against Hub'Eau's real values where both exist, as a
|
| 180 |
+
# sanity check on the whole pipeline -- they measure different things
|
| 181 |
+
# (Hub'Eau: official gauge catchment; this: BD TOPO polygon sum) so
|
| 182 |
+
# exact agreement isn't expected, but they should be in the same
|
| 183 |
+
# ballpark for a real gauge, not off by an order of magnitude.
|
| 184 |
+
both = nodes_df.dropna(subset=["catchment_area_km2", "cumulative_catchment_area_km2"])
|
| 185 |
+
if not both.empty:
|
| 186 |
+
both = both.copy()
|
| 187 |
+
both["ratio"] = both["cumulative_catchment_area_km2"] / both["catchment_area_km2"]
|
| 188 |
+
print(f" Cross-check vs Hub'Eau (real gauges only): ratio mean={both['ratio'].mean():.2f}, "
|
| 189 |
+
f"range=[{both['ratio'].min():.2f}, {both['ratio'].max():.2f}]")
|
| 190 |
+
print(both[["station_code", "catchment_area_km2", "cumulative_catchment_area_km2", "ratio"]]
|
| 191 |
+
.to_string(index=False))
|
| 192 |
+
|
| 193 |
+
nodes_df.to_csv(enriched_path, index=False)
|
| 194 |
+
print(f" updated {enriched_path}")
|
| 195 |
+
print()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def main() -> None:
|
| 199 |
+
parser = argparse.ArgumentParser(description="Compute cumulative catchment area from BD TOPO polygons")
|
| 200 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 201 |
+
args = parser.parse_args()
|
| 202 |
+
|
| 203 |
+
for basin_id, file_key in [(0, "eure"), (1, "risle")]:
|
| 204 |
+
run_for_basin(args.data_root, basin_id, file_key)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
main()
|
scripts/graph_building/compute_edge_width.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Derives approximate channel width per real tronçon (river reach
|
| 3 |
+
segment) from BD TOPO data already downloaded in this project --
|
| 4 |
+
surface_hydrographique.geojson (real water-surface polygons) and
|
| 5 |
+
troncon_hydrographique.geojson (real centerline geometry) -- no new
|
| 6 |
+
data source needed.
|
| 7 |
+
|
| 8 |
+
width_m = surface_polygon_area_m2 / troncon_length_m
|
| 9 |
+
|
| 10 |
+
This is real, derivable channel geometry, not a full cross-sectional
|
| 11 |
+
profile -- true cross-section (a real depth profile across the
|
| 12 |
+
channel) needs bathymetric/LIDAR survey data, which hasn't been
|
| 13 |
+
sourced or verified for this project. Width alone is still a
|
| 14 |
+
physically meaningful hydraulic signal: it's the dominant term in
|
| 15 |
+
Manning's-equation-style conveyance estimates, and the reason a wide
|
| 16 |
+
shallow reach and a narrow deep one carry very different discharge for
|
| 17 |
+
the same water level -- exactly the kind of physics a plain
|
| 18 |
+
distance/elevation-drop edge attribute can't express on its own.
|
| 19 |
+
|
| 20 |
+
Spatial join: each tronçon is matched to whichever real surface
|
| 21 |
+
polygon its centerline geometry falls within (or, failing that, the
|
| 22 |
+
nearest one within a small tolerance) -- a tronçon with no matching
|
| 23 |
+
surface polygon at all gets width_m = NaN, not a fabricated default,
|
| 24 |
+
since "no real surface polygon found" is meaningfully different from
|
| 25 |
+
"the channel is zero-width."
|
| 26 |
+
|
| 27 |
+
Usage:
|
| 28 |
+
python -m scripts.compute_edge_width --data-root datasets
|
| 29 |
+
"""
|
| 30 |
+
import argparse
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import geopandas as gpd
|
| 34 |
+
import pandas as pd
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def compute_widths(troncons: gpd.GeoDataFrame, surfaces: gpd.GeoDataFrame) -> pd.DataFrame:
|
| 38 |
+
"""
|
| 39 |
+
Real spatial join: for each tronçon, find the surface polygon(s) it
|
| 40 |
+
intersects, sum their real area (a tronçon can legitimately span
|
| 41 |
+
more than one mapped surface polygon), divide by real tronçon
|
| 42 |
+
length. Returns one row per tronçon with a real cleabs identifier
|
| 43 |
+
and width_m (NaN where no real surface polygon matched).
|
| 44 |
+
"""
|
| 45 |
+
troncons = troncons.copy()
|
| 46 |
+
troncons["length_m"] = troncons.geometry.length
|
| 47 |
+
|
| 48 |
+
joined = gpd.sjoin(troncons[["cleabs", "length_m", "geometry"]], surfaces[["geometry"]],
|
| 49 |
+
how="left", predicate="intersects")
|
| 50 |
+
|
| 51 |
+
area_by_troncon = surfaces.geometry.area
|
| 52 |
+
joined["surface_area_m2"] = joined["index_right"].map(
|
| 53 |
+
lambda idx: area_by_troncon.loc[idx] if pd.notna(idx) and idx in area_by_troncon.index else float("nan")
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
result = joined.groupby("cleabs").agg(
|
| 57 |
+
length_m=("length_m", "first"),
|
| 58 |
+
total_surface_area_m2=("surface_area_m2", lambda x: x.sum() if x.notna().any() else float("nan")),
|
| 59 |
+
).reset_index()
|
| 60 |
+
|
| 61 |
+
result["width_m"] = result["total_surface_area_m2"] / result["length_m"]
|
| 62 |
+
n_matched = result["width_m"].notna().sum()
|
| 63 |
+
print(f"{n_matched}/{len(result)} real tronçon(s) matched to a real surface polygon "
|
| 64 |
+
f"({len(result) - n_matched} have no real surface data -- width_m will be NaN for those, not fabricated)")
|
| 65 |
+
|
| 66 |
+
return result[["cleabs", "length_m", "width_m"]]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def main() -> None:
|
| 70 |
+
parser = argparse.ArgumentParser(description="Derive real channel width from BD TOPO surface polygons")
|
| 71 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 72 |
+
parser.add_argument("--output", type=Path, default=None)
|
| 73 |
+
args = parser.parse_args()
|
| 74 |
+
|
| 75 |
+
bdtopo_dir = args.data_root / "bdtopo_hydro"
|
| 76 |
+
troncons_path = bdtopo_dir / "troncon_hydrographique.geojson"
|
| 77 |
+
surfaces_path = bdtopo_dir / "surface_hydrographique.geojson"
|
| 78 |
+
|
| 79 |
+
if not troncons_path.exists() or not surfaces_path.exists():
|
| 80 |
+
print(f"Missing {troncons_path} or {surfaces_path} -- run download_bdtopo_hydro.py first.")
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
print(f"Loading real tronçons from {troncons_path}...")
|
| 84 |
+
troncons = gpd.read_file(troncons_path)
|
| 85 |
+
print(f"Loading real surface polygons from {surfaces_path}...")
|
| 86 |
+
surfaces = gpd.read_file(surfaces_path)
|
| 87 |
+
print(f"{len(troncons)} real tronçon(s), {len(surfaces)} real surface polygon(s)")
|
| 88 |
+
|
| 89 |
+
if troncons.crs != surfaces.crs:
|
| 90 |
+
print(f"CRS mismatch ({troncons.crs} vs {surfaces.crs}) -- reprojecting surfaces to match tronçons")
|
| 91 |
+
surfaces = surfaces.to_crs(troncons.crs)
|
| 92 |
+
|
| 93 |
+
widths = compute_widths(troncons, surfaces)
|
| 94 |
+
|
| 95 |
+
real_widths = widths["width_m"].dropna()
|
| 96 |
+
if len(real_widths) > 0:
|
| 97 |
+
print(f"\nReal width distribution: min={real_widths.min():.1f}m, median={real_widths.median():.1f}m, "
|
| 98 |
+
f"max={real_widths.max():.1f}m")
|
| 99 |
+
print("Sanity-check these against what you know of the real rivers before trusting them -- "
|
| 100 |
+
"an implausible max (e.g. hundreds of meters) usually means a tronçon matched an "
|
| 101 |
+
"unrelated, much larger surface polygon nearby, not a real channel width.")
|
| 102 |
+
|
| 103 |
+
output_path = args.output or (args.data_root / "edge_widths.csv")
|
| 104 |
+
widths.to_csv(output_path, index=False)
|
| 105 |
+
print(f"\nSaved to {output_path}")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
if __name__ == "__main__":
|
| 109 |
+
main()
|
scripts/graph_building/diagnose_confluences.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diagnose the confluence count in a saved reach graph: real tributary
|
| 3 |
+
junctions, or a data artifact (e.g. the same physical reach digitized
|
| 4 |
+
twice, or a tile-boundary duplication in BD TOPO).
|
| 5 |
+
|
| 6 |
+
Works two ways:
|
| 7 |
+
1. Against your EXISTING risle_nodes.csv/risle_edges.csv (no rerun
|
| 8 |
+
needed) -- checks whether each flagged confluence's incoming edges
|
| 9 |
+
have suspiciously similar distance_km/elevation_drop_m, which is
|
| 10 |
+
the signature of two near-identical tronçons rather than two
|
| 11 |
+
genuinely different ones.
|
| 12 |
+
2. Conclusively, once you rerun scripts/build_reach_graphs.py with the
|
| 13 |
+
cleabs fix -- if two incoming edges at the same node share the same
|
| 14 |
+
cleabs (tronçon ID), that's definitive: same tronçon counted twice,
|
| 15 |
+
not two different ones.
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
python -m scripts.diagnose_confluences --data-root datasets --basin risle
|
| 19 |
+
"""
|
| 20 |
+
import argparse
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def diagnose(nodes_path: Path, edges_path: Path, n_samples: int = 10) -> None:
|
| 27 |
+
nodes_df = pd.read_csv(nodes_path)
|
| 28 |
+
edges_df = pd.read_csv(edges_path)
|
| 29 |
+
|
| 30 |
+
has_is_confluence = "is_confluence" in nodes_df.columns
|
| 31 |
+
has_toponym = "toponym" in edges_df.columns
|
| 32 |
+
has_cleabs = "cleabs" in edges_df.columns
|
| 33 |
+
|
| 34 |
+
if has_is_confluence:
|
| 35 |
+
confluence_codes = set(nodes_df[nodes_df["is_confluence"]]["station_code"])
|
| 36 |
+
confluences = edges_df[edges_df["target"].isin(confluence_codes)]["target"].value_counts()
|
| 37 |
+
raw_in_degree = edges_df["target"].value_counts()
|
| 38 |
+
raw_confluences = raw_in_degree[raw_in_degree >= 2]
|
| 39 |
+
print(f"{len(confluence_codes)} REAL confluence(s) -- different named river, independent "
|
| 40 |
+
f"upstream source, not a split that rejoins downstream -- out of "
|
| 41 |
+
f"{len(raw_confluences)} node(s) with raw in-degree >= 2 "
|
| 42 |
+
f"({len(nodes_df)} total nodes, {len(edges_df)} edges)")
|
| 43 |
+
print(f" The gap ({len(raw_confluences) - len(confluence_codes)} nodes) is same-river "
|
| 44 |
+
f"segmentation and/or braided channels that split and rejoin -- both excluded "
|
| 45 |
+
f"from the count above, using the authoritative is_confluence column computed "
|
| 46 |
+
f"with full graph topology (this script can't re-derive the split-rejoin check "
|
| 47 |
+
f"from the flat CSV alone, which is exactly why that column exists).")
|
| 48 |
+
elif has_toponym:
|
| 49 |
+
name_counts = edges_df.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique()
|
| 50 |
+
confluences = name_counts[name_counts > 1]
|
| 51 |
+
raw_in_degree = edges_df["target"].value_counts()
|
| 52 |
+
raw_confluences = raw_in_degree[raw_in_degree >= 2]
|
| 53 |
+
print(f"{len(confluences)} REAL confluence(s) (different named river actually joining), "
|
| 54 |
+
f"out of {len(raw_confluences)} node(s) with raw in-degree >= 2 "
|
| 55 |
+
f"({len(nodes_df)} total nodes, {len(edges_df)} edges)")
|
| 56 |
+
print(f" The gap ({len(raw_confluences) - len(confluences)} nodes) is same-river fine "
|
| 57 |
+
f"tronçon segmentation, not real branching -- this is now excluded from the count.")
|
| 58 |
+
print(" NOTE: this file predates the split-rejoin fix (no is_confluence column) -- a "
|
| 59 |
+
"braided channel with differently-named branches that rejoin downstream would "
|
| 60 |
+
"still be miscounted here as a real confluence. Re-run "
|
| 61 |
+
"scripts/build_reach_graphs.py for the corrected count.")
|
| 62 |
+
else:
|
| 63 |
+
in_degree = edges_df["target"].value_counts()
|
| 64 |
+
confluences = in_degree[in_degree >= 2]
|
| 65 |
+
print(f"{len(confluences)} node(s) with in-degree >= 2, out of {len(nodes_df)} total nodes "
|
| 66 |
+
f"and {len(edges_df)} edges")
|
| 67 |
+
print()
|
| 68 |
+
print("NOTE: this edges.csv doesn't have a 'toponym' column -- it was generated before "
|
| 69 |
+
"the name-based confluence fix. Every number above uses the old, less reliable "
|
| 70 |
+
"in-degree >= 2 rule. Re-run scripts/build_reach_graphs.py for a corrected count "
|
| 71 |
+
"and for this script to give a definitive per-node answer instead of a proxy.")
|
| 72 |
+
print()
|
| 73 |
+
|
| 74 |
+
sample = confluences.head(n_samples)
|
| 75 |
+
n_confirmed_duplicate = 0
|
| 76 |
+
n_suspicious = 0
|
| 77 |
+
|
| 78 |
+
for node in sample.index:
|
| 79 |
+
incoming = edges_df[edges_df["target"] == node]
|
| 80 |
+
cols = ["source", "target", "distance_km", "elevation_drop_m"]
|
| 81 |
+
cols += [c for c in ("toponym", "cleabs") if c in incoming.columns]
|
| 82 |
+
print(f"Node {node}: {len(incoming)} incoming edge(s)")
|
| 83 |
+
print(incoming[cols].to_string(index=False))
|
| 84 |
+
|
| 85 |
+
if has_toponym:
|
| 86 |
+
n_names = incoming["toponym"].nunique()
|
| 87 |
+
if n_names > 1:
|
| 88 |
+
print(f" -> REAL: {n_names} distinct named river(s) join here.")
|
| 89 |
+
else:
|
| 90 |
+
print(f" -> Same toponym on every incoming edge -- not counted as a real "
|
| 91 |
+
f"confluence (this shouldn't appear in the sample above; if it does, "
|
| 92 |
+
f"something's inconsistent between this script and build_reach_graph.py).")
|
| 93 |
+
elif has_cleabs and incoming["cleabs"].notna().all():
|
| 94 |
+
if incoming["cleabs"].nunique() < len(incoming):
|
| 95 |
+
print(" -> CONFIRMED DUPLICATE: two+ incoming edges share the same cleabs "
|
| 96 |
+
"(same physical tronçon counted more than once).")
|
| 97 |
+
n_confirmed_duplicate += 1
|
| 98 |
+
else:
|
| 99 |
+
dists = incoming["distance_km"].dropna()
|
| 100 |
+
if len(dists) >= 2 and (dists.max() - dists.min()) < 0.05:
|
| 101 |
+
print(" -> SUSPICIOUS: incoming edges have near-identical distance_km "
|
| 102 |
+
"(within 50m) -- consistent with the same reach being counted twice, "
|
| 103 |
+
"not two distinct tributaries joining.")
|
| 104 |
+
n_suspicious += 1
|
| 105 |
+
print()
|
| 106 |
+
|
| 107 |
+
print("=" * 60)
|
| 108 |
+
if has_toponym:
|
| 109 |
+
print(f"All {len(sample)} sampled nodes have >1 distinct named river joining -- "
|
| 110 |
+
f"that's the definition being used, so this should always be 100%. If any "
|
| 111 |
+
f"printed 'Same toponym' above, report it as a bug.")
|
| 112 |
+
elif has_cleabs:
|
| 113 |
+
print(f"Of {len(sample)} sampled confluences: {n_confirmed_duplicate} confirmed "
|
| 114 |
+
f"duplicate-tronçon artifacts, {len(sample) - n_confirmed_duplicate} have "
|
| 115 |
+
f"genuinely distinct incoming tronçons (real confluence candidates).")
|
| 116 |
+
else:
|
| 117 |
+
print(f"Of {len(sample)} sampled confluences: {n_suspicious} look suspicious "
|
| 118 |
+
f"(near-identical incoming edge distances). Re-run scripts/build_reach_graphs.py "
|
| 119 |
+
f"for a conclusive, name-based answer instead of this proxy signal.")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def main() -> None:
|
| 123 |
+
parser = argparse.ArgumentParser(description="Diagnose reach-graph confluence counts")
|
| 124 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 125 |
+
parser.add_argument("--basin", choices=["eure", "risle"], required=True)
|
| 126 |
+
parser.add_argument("--n-samples", type=int, default=10)
|
| 127 |
+
args = parser.parse_args()
|
| 128 |
+
|
| 129 |
+
graph_dir = args.data_root / "reach_graph"
|
| 130 |
+
diagnose(graph_dir / f"{args.basin}_nodes.csv", graph_dir / f"{args.basin}_edges.csv",
|
| 131 |
+
n_samples=args.n_samples)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
main()
|
scripts/graph_building/diagnose_persistence_baseline.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diagnostic: adds a naive persistence baseline (tomorrow's anomaly =
|
| 3 |
+
today's real anomaly, no model at all) to the climatology comparison,
|
| 4 |
+
computed directly from real anomaly data -- no trained model needed.
|
| 5 |
+
|
| 6 |
+
This exists specifically to distinguish two very different explanations
|
| 7 |
+
for the anomaly model losing to climatology at short horizons:
|
| 8 |
+
(a) there's genuinely no learnable short-term signal beyond
|
| 9 |
+
climatology for that variable, or
|
| 10 |
+
(b) the model/training setup has a real problem, and even the
|
| 11 |
+
simplest possible non-climatology guess (yesterday's value)
|
| 12 |
+
would have beaten climatology if given the chance.
|
| 13 |
+
|
| 14 |
+
If persistence beats climatology at h=1 but the trained model doesn't,
|
| 15 |
+
that's a training/architecture issue, not a genuine predictability
|
| 16 |
+
limit -- a very different, much more actionable finding.
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python -m scripts.diagnose_persistence_baseline --data-root datasets \
|
| 20 |
+
--train-end 2023-12-31 --test-end 2026-12-31
|
| 21 |
+
"""
|
| 22 |
+
import argparse
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import pandas as pd
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from src.graph.dynamic_features import build_climate_grid_timeseries
|
| 30 |
+
from src.graph.climate_climatology import fit_harmonic_climatology, compute_anomalies
|
| 31 |
+
except ImportError:
|
| 32 |
+
import sys
|
| 33 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 34 |
+
from src.graph.dynamic_features import build_climate_grid_timeseries
|
| 35 |
+
from src.graph.climate_climatology import fit_harmonic_climatology, compute_anomalies
|
| 36 |
+
|
| 37 |
+
DEFAULT_HORIZONS = [1, 5, 10, 30, 60, 90, 180]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def evaluate_persistence(anomalies: pd.DataFrame, horizons: list) -> pd.DataFrame:
|
| 41 |
+
"""
|
| 42 |
+
For every real (date, cell) anomaly value, compares:
|
| 43 |
+
- persistence_rmse: predicting that SAME day's anomaly repeated
|
| 44 |
+
forward h days (i.e. "nothing has changed since today")
|
| 45 |
+
- climatology_only_rmse: predicting zero anomaly (pure climatology)
|
| 46 |
+
against the REAL anomaly that actually occurred h days later.
|
| 47 |
+
Both computed only where the real target h days ahead exists.
|
| 48 |
+
"""
|
| 49 |
+
values = anomalies.values
|
| 50 |
+
n_dates, n_cells = values.shape
|
| 51 |
+
rows = []
|
| 52 |
+
|
| 53 |
+
for h in horizons:
|
| 54 |
+
if h >= n_dates:
|
| 55 |
+
continue
|
| 56 |
+
today = values[:n_dates - h, :]
|
| 57 |
+
future = values[h:, :]
|
| 58 |
+
valid = ~np.isnan(today) & ~np.isnan(future)
|
| 59 |
+
if not valid.any():
|
| 60 |
+
continue
|
| 61 |
+
persistence_pred = today[valid] # "tomorrow = today"
|
| 62 |
+
actual = future[valid]
|
| 63 |
+
persistence_rmse = np.sqrt(np.mean((persistence_pred - actual) ** 2))
|
| 64 |
+
climatology_rmse = np.sqrt(np.mean(actual ** 2)) # predicting anomaly = 0
|
| 65 |
+
rows.append({
|
| 66 |
+
"horizon_days": h, "n_pairs": int(valid.sum()),
|
| 67 |
+
"persistence_rmse": persistence_rmse, "climatology_only_rmse": climatology_rmse,
|
| 68 |
+
"persistence_beats_climatology": persistence_rmse < climatology_rmse,
|
| 69 |
+
})
|
| 70 |
+
return pd.DataFrame(rows)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def main() -> None:
|
| 74 |
+
parser = argparse.ArgumentParser(description="Persistence-vs-climatology diagnostic")
|
| 75 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 76 |
+
parser.add_argument("--stations-path", type=Path, default=None)
|
| 77 |
+
parser.add_argument("--bbox-margin-deg", type=float, default=0.3)
|
| 78 |
+
parser.add_argument("--train-start", type=str, default="2013-01-01")
|
| 79 |
+
parser.add_argument("--train-end", type=str, default="2023-12-31")
|
| 80 |
+
parser.add_argument("--test-end", type=str, default="2026-12-31")
|
| 81 |
+
parser.add_argument("--horizons", type=int, nargs="+", default=DEFAULT_HORIZONS)
|
| 82 |
+
parser.add_argument("--n-harmonics", type=int, default=2)
|
| 83 |
+
parser.add_argument("--min-months-covered", type=int, default=8)
|
| 84 |
+
args = parser.parse_args()
|
| 85 |
+
|
| 86 |
+
stations_path = args.stations_path or (args.data_root / "station_elevations.csv")
|
| 87 |
+
nodes_df = pd.read_csv(stations_path)
|
| 88 |
+
m = args.bbox_margin_deg
|
| 89 |
+
bbox = (nodes_df["longitude"].min() - m, nodes_df["latitude"].min() - m,
|
| 90 |
+
nodes_df["longitude"].max() + m, nodes_df["latitude"].max() + m)
|
| 91 |
+
|
| 92 |
+
print("Loading real ERA5 grid data...")
|
| 93 |
+
climate_dict, grid_coords = build_climate_grid_timeseries(
|
| 94 |
+
args.data_root / "safran", bbox, (args.train_start, args.test_end)
|
| 95 |
+
)
|
| 96 |
+
print(f"Loaded {len(climate_dict)} real variable(s) across {len(grid_coords)} real grid cell(s)\n")
|
| 97 |
+
|
| 98 |
+
train_start, train_end, test_end = (
|
| 99 |
+
pd.Timestamp(args.train_start), pd.Timestamp(args.train_end), pd.Timestamp(args.test_end)
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
print("=" * 80)
|
| 103 |
+
print(f"{'Variable':<15}{'Horizon':>8} {'Persistence RMSE':>17} {'Climatology RMSE':>17} {'Persistence wins?':>18}")
|
| 104 |
+
print("=" * 80)
|
| 105 |
+
|
| 106 |
+
for var, wide in climate_dict.items():
|
| 107 |
+
train_wide = wide[(wide.index >= train_start) & (wide.index <= train_end)]
|
| 108 |
+
test_wide = wide[(wide.index > train_end) & (wide.index <= test_end)]
|
| 109 |
+
|
| 110 |
+
coeffs = fit_harmonic_climatology(train_wide, n_harmonics=args.n_harmonics,
|
| 111 |
+
min_months_covered=args.min_months_covered)
|
| 112 |
+
if not coeffs:
|
| 113 |
+
continue
|
| 114 |
+
test_anomalies = compute_anomalies(test_wide, coeffs, args.n_harmonics)
|
| 115 |
+
result = evaluate_persistence(test_anomalies, args.horizons)
|
| 116 |
+
|
| 117 |
+
for _, row in result.iterrows():
|
| 118 |
+
print(f"{var:<15}{int(row['horizon_days']):>8} {row['persistence_rmse']:>17.4f} "
|
| 119 |
+
f"{row['climatology_only_rmse']:>17.4f} {str(row['persistence_beats_climatology']):>18}")
|
| 120 |
+
print()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
main()
|
scripts/graph_building/enrich_reach_graph.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enriches the reach graph's node tables (from scripts/build_reach_graphs.py)
|
| 3 |
+
with every available feature loader, via node_features.py's base_nodes_df
|
| 4 |
+
path -- closing the gap where the reach graph and the feature pipeline
|
| 5 |
+
existed separately but nothing actually connected them.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python -m scripts.enrich_reach_graph --data-root datasets
|
| 9 |
+
"""
|
| 10 |
+
import argparse
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from src.graph.node_features import build_node_features
|
| 18 |
+
except ImportError:
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 20 |
+
from src.graph.node_features import build_node_features
|
| 21 |
+
|
| 22 |
+
BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def enrich_basin(
|
| 26 |
+
data_root: Path, basin_id: int, skip_climate: bool = False,
|
| 27 |
+
date_range: "tuple[str, str] | None" = None,
|
| 28 |
+
) -> None:
|
| 29 |
+
file_key = BASIN_FILE_NAMES[basin_id]
|
| 30 |
+
graph_dir = data_root / "reach_graph"
|
| 31 |
+
nodes_path = graph_dir / f"{file_key}_nodes.csv"
|
| 32 |
+
|
| 33 |
+
if not nodes_path.exists():
|
| 34 |
+
print(f"{file_key}: no {nodes_path} found -- run scripts/build_reach_graphs.py first. Skipping.")
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
base_nodes = pd.read_csv(nodes_path)
|
| 38 |
+
print(f"{file_key}: {len(base_nodes)} nodes loaded from {nodes_path}")
|
| 39 |
+
|
| 40 |
+
enriched, report = build_node_features(
|
| 41 |
+
base_nodes_df=base_nodes,
|
| 42 |
+
idpr_path=data_root / "idpr.csv",
|
| 43 |
+
ades_path=data_root / "ades",
|
| 44 |
+
safran_path=None if skip_climate else data_root / "safran",
|
| 45 |
+
# --skip-climate exists specifically because add_safran_features has
|
| 46 |
+
# not been verified at reach-graph scale (~2,900 nodes) and is the
|
| 47 |
+
# prime suspect for a real hang there, unlike everything else in
|
| 48 |
+
# this pipeline which has been checked at this scale already (see
|
| 49 |
+
# add_groundwater_features's rewrite, which hit exactly this class
|
| 50 |
+
# of bug once before). Use this flag to get unblocked on everything
|
| 51 |
+
# else while that gets diagnosed with the real source.
|
| 52 |
+
catchment_path=data_root / "catchment_area.csv",
|
| 53 |
+
hydrometric_path=data_root / "hydrometric",
|
| 54 |
+
bdcavites_path=data_root / "bdcavites" / "cavite_localisee.geojson",
|
| 55 |
+
# Real, confirmed bug fix: this was never passed at all before,
|
| 56 |
+
# despite add_cavites_features and its real KD-tree/haversine
|
| 57 |
+
# logic being fully built and already generic over base_nodes_df
|
| 58 |
+
# -- the reach graph's ~4,500 nodes never got a real cavity
|
| 59 |
+
# feature purely because this one call site never wired it in,
|
| 60 |
+
# not because of any real gauge-only restriction in the feature
|
| 61 |
+
# code itself. Defaults to the "naturelle"-only filter inside
|
| 62 |
+
# add_cavites_features -- confirmed via scripts/analysis/
|
| 63 |
+
# check_cavity_proximity.py as the real, defensible choice
|
| 64 |
+
# (an unfiltered check found 24/27 real gauges within 1km of
|
| 65 |
+
# *some* cavity, dominated by quarries/civil structures with no
|
| 66 |
+
# hydrological meaning; "naturelle" is what actually indicates
|
| 67 |
+
# real karst connectivity).
|
| 68 |
+
date_range=date_range,
|
| 69 |
+
# Safe to include here, unlike an earlier version of this script
|
| 70 |
+
# assumed: target_* attaches via a left-merge on station_code,
|
| 71 |
+
# and real gauge codes ("H...") can never collide with BD TOPO
|
| 72 |
+
# hydrographic node IDs ("NOEUDHYD...") or virtual infill node
|
| 73 |
+
# IDs ("VIRTUAL::..."), confirmed against the real station code
|
| 74 |
+
# namespace -- so only real gauge rows ever get a real target
|
| 75 |
+
# value; every confluence/virtual node correctly gets NaN, not
|
| 76 |
+
# a mislabeled one.
|
| 77 |
+
)
|
| 78 |
+
print(report)
|
| 79 |
+
|
| 80 |
+
n_climate = enriched["climate_precip_mm"].notna().sum() if "climate_precip_mm" in enriched.columns else 0
|
| 81 |
+
n_catchment = enriched["catchment_area_km2"].notna().sum() if "catchment_area_km2" in enriched.columns else 0
|
| 82 |
+
n_targets = enriched["target_discharge_m3s_mean"].notna().sum() if "target_discharge_m3s_mean" in enriched.columns else 0
|
| 83 |
+
n_cavites = enriched["distance_to_nearest_cavity_km"].notna().sum() if "distance_to_nearest_cavity_km" in enriched.columns else 0
|
| 84 |
+
print(f" climate coverage: {n_climate}/{len(enriched)} nodes")
|
| 85 |
+
print(f" target (discharge) coverage: {n_targets}/{len(enriched)} nodes "
|
| 86 |
+
f"(expected: only real gauges -- {int(base_nodes['is_gauged'].sum())} in this basin)")
|
| 87 |
+
print(f" catchment_area coverage: {n_catchment}/{len(enriched)} nodes "
|
| 88 |
+
f"(expected: only real gauges have one -- see node_features.py's "
|
| 89 |
+
f"add_catchment_features docstring; this is what water_balance_loss "
|
| 90 |
+
f"can actually use right now, everything else needs a cumulative "
|
| 91 |
+
f"catchment estimate this script doesn't build)")
|
| 92 |
+
print(f" cavity proximity coverage: {n_cavites}/{len(enriched)} nodes "
|
| 93 |
+
f"(graph-wide, naturelle-only filter -- see add_cavites_features)")
|
| 94 |
+
|
| 95 |
+
out_path = graph_dir / f"{file_key}_nodes_enriched.csv"
|
| 96 |
+
enriched.to_csv(out_path, index=False)
|
| 97 |
+
print(f" saved to {out_path}")
|
| 98 |
+
print()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def main() -> None:
|
| 102 |
+
parser = argparse.ArgumentParser(description="Enrich reach graph nodes with feature loaders")
|
| 103 |
+
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
|
| 104 |
+
parser.add_argument("--skip-climate", action="store_true",
|
| 105 |
+
help="Skip SAFRAN/ERA5 climate features -- use if that step hangs at reach-graph scale")
|
| 106 |
+
parser.add_argument("--start-date", type=str, default="2013-01-01",
|
| 107 |
+
help="Start of the training period (inclusive). Default 2013-01-01: the window "
|
| 108 |
+
"maximizing discharge-gauge coverage (6/8 stations, 13,084 observations) -- "
|
| 109 |
+
"see the date-range analysis this default came from before changing it.")
|
| 110 |
+
parser.add_argument("--end-date", type=str, default="2026-12-31",
|
| 111 |
+
help="End of the training period (inclusive).")
|
| 112 |
+
parser.add_argument("--no-date-filter", action="store_true",
|
| 113 |
+
help="Disable date filtering entirely -- each source uses its own full history")
|
| 114 |
+
args = parser.parse_args()
|
| 115 |
+
|
| 116 |
+
date_range = None if args.no_date_filter else (args.start_date, args.end_date)
|
| 117 |
+
if date_range:
|
| 118 |
+
print(f"Using date range: {date_range[0]} to {date_range[1]}")
|
| 119 |
+
print()
|
| 120 |
+
|
| 121 |
+
for basin_id in BASIN_FILE_NAMES:
|
| 122 |
+
enrich_basin(args.data_root, basin_id, skip_climate=args.skip_climate, date_range=date_range)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
if __name__ == "__main__":
|
| 126 |
+
main()
|
scripts/tests/__pycache__/cross_validate.cpython-311.pyc
ADDED
|
Binary file (14.1 kB). View file
|
|
|
scripts/tests/__pycache__/test_climate_climatology.cpython-311.pyc
ADDED
|
Binary file (8.7 kB). View file
|
|
|
scripts/tests/__pycache__/test_climate_climatology_grid.cpython-311.pyc
ADDED
|
Binary file (8.8 kB). View file
|
|
|