ageraustine commited on
Commit
f2046b4
·
verified ·
1 Parent(s): 4ae0c9e

Upload folder using huggingface_hub (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. scripts/__pycache__/compute_edge_width.cpython-311.pyc +0 -0
  3. scripts/__pycache__/diagnose_persistence_baseline.cpython-311.pyc +0 -0
  4. scripts/__pycache__/download_c3s_seasonal.cpython-311.pyc +0 -0
  5. scripts/__pycache__/fetch_ecmwf_forecast.cpython-311.pyc +0 -0
  6. scripts/__pycache__/fetch_eure_risle_stations.cpython-311.pyc +0 -0
  7. scripts/__pycache__/fetch_previous_runs.cpython-311.pyc +0 -0
  8. scripts/__pycache__/identify_flood_events.cpython-311.pyc +0 -0
  9. scripts/__pycache__/train_spatiotemporal_gnn.cpython-311.pyc +0 -0
  10. scripts/__pycache__/validate_forecast_skill.cpython-311.pyc +0 -0
  11. scripts/analysis/__pycache__/check_cavity_proximity.cpython-311.pyc +0 -0
  12. scripts/analysis/__pycache__/check_forecast_lead5_anomaly.cpython-311.pyc +0 -0
  13. scripts/analysis/__pycache__/trace_forecast_lookup.cpython-311.pyc +0 -0
  14. scripts/analysis/analyze_bdtopo_hydro.py +245 -0
  15. scripts/analysis/check_cavity_proximity.py +169 -0
  16. scripts/analysis/check_forecast_lead5_anomaly.py +120 -0
  17. scripts/analysis/cross_check_catchments.py +50 -0
  18. scripts/analysis/run_bdtopo_checks.py +71 -0
  19. scripts/analysis/trace_forecast_lookup.py +127 -0
  20. scripts/data_acquisition/__pycache__/fetch_meteofrance_precipitation.cpython-311.pyc +0 -0
  21. scripts/data_acquisition/download_bdcavites.py +146 -0
  22. scripts/data_acquisition/download_bdcharm.py +107 -0
  23. scripts/data_acquisition/download_bdtopo_hydro.py +244 -0
  24. scripts/data_acquisition/download_c3s_seasonal.py +181 -0
  25. scripts/data_acquisition/download_catchment.py +114 -0
  26. scripts/data_acquisition/download_elevation.py +67 -0
  27. scripts/data_acquisition/download_era5_full.py +180 -0
  28. scripts/data_acquisition/download_era5_sample.py +44 -0
  29. scripts/data_acquisition/download_hubeau.py +162 -0
  30. scripts/data_acquisition/extract_era5.py +57 -0
  31. scripts/data_acquisition/fetch_ecmwf_forecast.py +152 -0
  32. scripts/data_acquisition/fetch_idpr_brgm.py +144 -0
  33. scripts/data_acquisition/fetch_landcover.py +134 -0
  34. scripts/data_acquisition/fetch_meteofrance_precipitation.py +240 -0
  35. scripts/data_acquisition/fetch_previous_runs.py +175 -0
  36. scripts/data_acquisition/fetch_worldcover_ndvi.py +155 -0
  37. scripts/evaluation/__pycache__/evaluate_flood_detection.cpython-311.pyc +0 -0
  38. scripts/evaluation/evaluate_flood_detection.py +986 -0
  39. scripts/evaluation/identify_flood_events.py +151 -0
  40. scripts/graph_building/__pycache__/enrich_reach_graph.cpython-311.pyc +0 -0
  41. scripts/graph_building/build_dynamic_tensors.py +117 -0
  42. scripts/graph_building/build_reach_graphs.py +188 -0
  43. scripts/graph_building/compute_cumulative_catchment.py +208 -0
  44. scripts/graph_building/compute_edge_width.py +109 -0
  45. scripts/graph_building/diagnose_confluences.py +135 -0
  46. scripts/graph_building/diagnose_persistence_baseline.py +124 -0
  47. scripts/graph_building/enrich_reach_graph.py +126 -0
  48. scripts/tests/__pycache__/cross_validate.cpython-311.pyc +0 -0
  49. scripts/tests/__pycache__/test_climate_climatology.cpython-311.pyc +0 -0
  50. 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