ageraustine commited on
Commit
4bb7968
·
verified ·
1 Parent(s): 3e2cadd

Upload folder using huggingface_hub (part 2)

Browse files
scripts/__pycache__/fetch_idpr_brgm.cpython-311.pyc ADDED
Binary file (8.16 kB). View file
 
scripts/__pycache__/fetch_landcover.cpython-311.pyc ADDED
Binary file (9.14 kB). View file
 
scripts/__pycache__/fetch_worldcover_ndvi.cpython-311.pyc ADDED
Binary file (10.1 kB). View file
 
scripts/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/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/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/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/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/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()
src/graph/build_graph.py CHANGED
@@ -302,6 +302,22 @@ def build_pyg_graph(
302
  "is_gauged", "is_confluence", "is_split_point", "is_rejoin_point",
303
  "snap_distance_km", "braid_id",
304
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  target_cols = [c for c in nodes_df.columns if c.startswith("target_")]
306
  if feature_columns is None:
307
  feature_columns = [
@@ -368,6 +384,28 @@ def build_pyg_graph(
368
  data.feature_names = all_feature_cols
369
  data.basin_id = torch.tensor(nodes_df["basin_id"].values, dtype=torch.long)
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  for bool_col in ("is_gauged", "is_confluence", "is_split_point", "is_rejoin_point"):
372
  if bool_col in nodes_df.columns:
373
  setattr(data, bool_col, torch.tensor(nodes_df[bool_col].values, dtype=torch.bool))
 
302
  "is_gauged", "is_confluence", "is_split_point", "is_rejoin_point",
303
  "snap_distance_km", "braid_id",
304
  }
305
+ # Feature columns that are genuinely time-varying in reality, even
306
+ # though today's pipeline only ever produces a period-aggregate
307
+ # value for them (see node_features.py's add_safran_features/
308
+ # add_groundwater_features/add_ndvi_features) -- distinct from
309
+ # STATIC_COLUMNS below, which are physically time-invariant (a
310
+ # location's elevation or IDPR index doesn't change on any
311
+ # timescale relevant here). n_nearby_wells is classified static
312
+ # despite living under the groundwater loader: it describes monitor
313
+ # coverage (which wells exist nearby), not a water-table quantity
314
+ # that itself varies day to day.
315
+ DYNAMIC_PREFIXES = ("climate_", "avg_groundwater_level", "avg_groundwater_depth", "ndvi_")
316
+
317
+ def _is_dynamic(col: str) -> bool:
318
+ base = col[:-len("__was_missing")] if col.endswith("__was_missing") else col
319
+ return any(base.startswith(p) for p in DYNAMIC_PREFIXES)
320
+
321
  target_cols = [c for c in nodes_df.columns if c.startswith("target_")]
322
  if feature_columns is None:
323
  feature_columns = [
 
384
  data.feature_names = all_feature_cols
385
  data.basin_id = torch.tensor(nodes_df["basin_id"].values, dtype=torch.long)
386
 
387
+ # Static/dynamic split: `x` stays the full combined tensor (nothing
388
+ # existing that reads data.x breaks), but every feature is also
389
+ # tagged and split out separately -- x_static for physically
390
+ # time-invariant quantities (elevation, IDPR, catchment area,
391
+ # landcover), x_dynamic for quantities that vary in reality even
392
+ # though the current pipeline only ever hands them over as a single
393
+ # period-aggregate (climate, groundwater level/depth, NDVI). See
394
+ # DYNAMIC_PREFIXES above for exactly which columns land where, and
395
+ # node_features.py's add_safran_features/add_groundwater_features/
396
+ # add_ndvi_features docstrings for why "dynamic" here still means
397
+ # "one aggregated number," not a real time series yet -- a genuine
398
+ # multi-timestep pipeline is a separate, larger piece of future work
399
+ # this split does not attempt to solve on its own.
400
+ dynamic_mask = [_is_dynamic(c) for c in all_feature_cols]
401
+ static_idx = [i for i, d in enumerate(dynamic_mask) if not d]
402
+ dynamic_idx = [i for i, d in enumerate(dynamic_mask) if d]
403
+ data.static_feature_names = [all_feature_cols[i] for i in static_idx]
404
+ data.dynamic_feature_names = [all_feature_cols[i] for i in dynamic_idx]
405
+ x_np_for_split = feat[all_feature_cols].values
406
+ data.x_static = torch.tensor(x_np_for_split[:, static_idx], dtype=torch.float) if static_idx else None
407
+ data.x_dynamic = torch.tensor(x_np_for_split[:, dynamic_idx], dtype=torch.float) if dynamic_idx else None
408
+
409
  for bool_col in ("is_gauged", "is_confluence", "is_split_point", "is_rejoin_point"):
410
  if bool_col in nodes_df.columns:
411
  setattr(data, bool_col, torch.tensor(nodes_df[bool_col].values, dtype=torch.bool))
src/graph/dynamic_features.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Builds genuine [n_nodes, T] time series for the features that
3
+ node_features.py's add_safran_features/add_groundwater_features/
4
+ add_hydrometric_target_stats only ever hand over as a single period-
5
+ aggregated number. That aggregation was always a real, named
6
+ limitation (see build_graph.py's DYNAMIC_PREFIXES docstring and the
7
+ README's §2.3) -- this module is what actually closes it.
8
+
9
+ physics_losses.py's routing_consistency_loss specifically needs a real
10
+ time dimension and had nothing to consume before this existed.
11
+
12
+ Three sources, three different real challenges:
13
+ - discharge: already daily, already per-station -- just needs
14
+ pivoting to wide form, restricted to real gauges (no fabrication
15
+ for non-gauge nodes).
16
+ - groundwater: wells report on wildly irregular schedules (confirmed
17
+ against real data: 13 different "latest dates" among 18 wells
18
+ within 20km of one station). Needs resampling to a common daily
19
+ grid via forward-fill (a well's level changes slowly -- carrying
20
+ the last known reading forward is standard practice for sparse
21
+ level data, not an invented shortcut) BEFORE the spatial average,
22
+ not after -- averaging raw irregular readings per exact calendar
23
+ date is exactly the bug that made the old "aggregate_to_stations"
24
+ path undercount real well coverage by 5-10x (see node_features.py's
25
+ add_groundwater_features docstring).
26
+ - climate: SAFRANLoader.load() already returns per-date rows before
27
+ node_features.py's add_safran_features aggregates them -- this
28
+ just skips that aggregation step and pivots instead. UNTESTED in
29
+ this environment: no real ERA5/safran files were available here to
30
+ validate against (only real ADES and hydrometric data were).
31
+ """
32
+ from pathlib import Path
33
+ from typing import Dict, List, Optional, Tuple
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+
38
+
39
+ def build_discharge_timeseries(
40
+ nodes_df: pd.DataFrame, hydrometric_path: Path, date_range: Tuple[str, str],
41
+ freq: str = "D",
42
+ ) -> pd.DataFrame:
43
+ """
44
+ Real daily QmnJ discharge, pivoted to [date x station_code] wide
45
+ form, restricted to the date range and reindexed onto a regular
46
+ daily grid (missing days stay NaN -- no fabrication).
47
+
48
+ Deliberately does NOT forward-fill discharge the way groundwater
49
+ gets resampled below: discharge genuinely changes day to day and a
50
+ missing daily reading should stay missing, not be papered over with
51
+ the previous day's value the way a slowly-changing water table can
52
+ reasonably be.
53
+
54
+ Only real gauge station_codes appear as columns -- confluences and
55
+ virtual nodes never had discharge observations to begin with, same
56
+ principle as the period-aggregate target_* columns.
57
+ """
58
+ from ..data.loaders.hydrometric import HydrometricLoader
59
+
60
+ loader = HydrometricLoader(data_path=hydrometric_path)
61
+ df = loader.load()
62
+ if "discharge_m3s" not in df.columns:
63
+ raise ValueError("Loaded hydrometric data has no discharge_m3s column")
64
+
65
+ start, end = date_range
66
+ df = df[(df["date"] >= start) & (df["date"] <= end)]
67
+ wide = df.pivot_table(index="date", columns="station_code", values="discharge_m3s", aggfunc="mean")
68
+
69
+ full_index = pd.date_range(start, end, freq=freq)
70
+ wide = wide.reindex(full_index)
71
+ wide.index.name = "date"
72
+ return wide
73
+
74
+
75
+ def build_groundwater_timeseries(
76
+ nodes_df: pd.DataFrame, ades_path: Path, date_range: Tuple[str, str],
77
+ max_distance_km: float = 20.0, freq: str = "D",
78
+ ) -> Tuple[pd.DataFrame, pd.DataFrame]:
79
+ """
80
+ Real groundwater level, resampled to a daily grid per well
81
+ (forward-fill) BEFORE spatial averaging, then averaged per node
82
+ using the SAME nearby-well set every day -- the spatial join
83
+ (which wells are near which node) is time-invariant, so it's
84
+ computed once, not repeated per date. That's what keeps this
85
+ tractable at real reach-graph scale: O(n_nodes) spatial lookups
86
+ total, not O(n_nodes x n_dates).
87
+
88
+ Returns:
89
+ (level_wide, depth_wide) -- both [date x station_code], node
90
+ ordering matching nodes_df. A node with zero wells within
91
+ max_distance_km gets NaN for every date, not zero -- "no
92
+ nearby monitoring" is meaningfully different from "measured
93
+ zero," same principle as node_features.py's static version.
94
+ """
95
+ from ..data.loaders.ades import ADESLoader
96
+ from scipy.spatial import cKDTree
97
+
98
+ loader = ADESLoader(data_path=ades_path)
99
+ gw_df = loader.load()
100
+ start, end = date_range
101
+ full_index = pd.date_range(start, end, freq=freq)
102
+
103
+ # Resample each well independently to the common daily grid, forward-fill.
104
+ wells_wide = gw_df.pivot_table(index="date", columns="code_bss", values="groundwater_level_m", aggfunc="mean")
105
+ wells_wide = wells_wide.reindex(wells_wide.index.union(full_index)).sort_index().ffill()
106
+ wells_wide = wells_wide.reindex(full_index)
107
+
108
+ depth_wide_raw = gw_df.pivot_table(index="date", columns="code_bss", values="groundwater_depth_m", aggfunc="mean") \
109
+ if "groundwater_depth_m" in gw_df.columns else None
110
+ if depth_wide_raw is not None:
111
+ depth_wide_raw = depth_wide_raw.reindex(depth_wide_raw.index.union(full_index)).sort_index().ffill()
112
+ depth_wide_raw = depth_wide_raw.reindex(full_index)
113
+
114
+ well_coords = gw_df.drop_duplicates("code_bss").set_index("code_bss")[["lat", "lon"]]
115
+ well_coords = well_coords.reindex(wells_wide.columns) # align to the pivoted columns' order
116
+ tree = cKDTree(well_coords[["lon", "lat"]].values)
117
+ coarse_radius_deg = (max_distance_km / 111.0) * 1.5
118
+
119
+ def _haversine_km(lat1, lon1, lat2, lon2):
120
+ R = 6371.0
121
+ lat1r, lon1r, lat2r, lon2r = map(np.radians, [lat1, lon1, lat2, lon2])
122
+ dlat, dlon = lat2r - lat1r, lon2r - lon1r
123
+ a = np.sin(dlat / 2) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2) ** 2
124
+ return R * 2 * np.arcsin(np.sqrt(a))
125
+
126
+ station_lon = nodes_df["longitude"].values
127
+ station_lat = nodes_df["latitude"].values
128
+ candidate_lists = tree.query_ball_point(np.column_stack([station_lon, station_lat]), r=coarse_radius_deg)
129
+
130
+ level_cols, depth_cols = {}, {}
131
+ for i, station_code in enumerate(nodes_df["station_code"]):
132
+ candidates = candidate_lists[i]
133
+ if not candidates:
134
+ level_cols[station_code] = pd.Series(np.nan, index=full_index)
135
+ depth_cols[station_code] = pd.Series(np.nan, index=full_index)
136
+ continue
137
+ cand_idx = np.array(candidates)
138
+ cand_lat = well_coords["lat"].values[cand_idx]
139
+ cand_lon = well_coords["lon"].values[cand_idx]
140
+ dist = _haversine_km(station_lat[i], station_lon[i], cand_lat, cand_lon)
141
+ within = cand_idx[dist <= max_distance_km]
142
+ if len(within) == 0:
143
+ level_cols[station_code] = pd.Series(np.nan, index=full_index)
144
+ depth_cols[station_code] = pd.Series(np.nan, index=full_index)
145
+ continue
146
+ well_names = wells_wide.columns[within]
147
+ level_cols[station_code] = wells_wide[well_names].mean(axis=1)
148
+ if depth_wide_raw is not None:
149
+ depth_cols[station_code] = depth_wide_raw[well_names].mean(axis=1)
150
+ else:
151
+ depth_cols[station_code] = pd.Series(np.nan, index=full_index)
152
+
153
+ level_wide = pd.DataFrame(level_cols)
154
+ depth_wide = pd.DataFrame(depth_cols)
155
+ return level_wide, depth_wide
156
+
157
+
158
+ def build_climate_timeseries(
159
+ nodes_df: pd.DataFrame, safran_path: Path, date_range: Tuple[str, str],
160
+ ) -> Dict[str, pd.DataFrame]:
161
+ """
162
+ Real per-date ERA5 variables, pivoted per variable to [date x
163
+ station_code] wide form -- skips node_features.py's add_safran_
164
+ features aggregation step entirely rather than reversing it.
165
+
166
+ UNTESTED in this environment: no real era5_*.nc files were
167
+ available here (only ADES and hydrometric real data were). The
168
+ logic mirrors build_discharge_timeseries's pivot pattern directly,
169
+ but please verify the real output shape/values before trusting it
170
+ for training.
171
+ """
172
+ from ..data.loaders.safran import SAFRANLoader
173
+
174
+ station_coords = nodes_df.rename(columns={"latitude": "lat", "longitude": "lon"})[
175
+ ["station_code", "lat", "lon"]
176
+ ]
177
+ loader = SAFRANLoader(data_path=safran_path, station_coords=station_coords)
178
+ df = loader.load()
179
+ df = loader.convert_units(df)
180
+
181
+ start, end = date_range
182
+ df = df[(df["date"] >= start) & (df["date"] <= end)]
183
+
184
+ var_cols = [c for c in df.columns if c not in ("date", "station_code")]
185
+ result = {}
186
+ for var in var_cols:
187
+ wide = df.pivot_table(index="date", columns="station_code", values=var, aggfunc="mean")
188
+ result[var] = wide
189
+ return result
190
+
191
+
192
+ def assemble_dynamic_tensor(nodes_df: pd.DataFrame, wide_df: pd.DataFrame) -> Tuple[np.ndarray, List[pd.Timestamp]]:
193
+ """
194
+ Aligns a [date x station_code] wide DataFrame onto nodes_df's own
195
+ row order, so the result matches data.x's node indexing exactly --
196
+ this is the piece that makes a build_*_timeseries output directly
197
+ usable as physics_losses.py's routing_consistency_loss's `Q`
198
+ argument (shape [n_nodes, T]).
199
+
200
+ A node whose station_code never appears as a column (every
201
+ confluence/virtual node, for discharge) gets an all-NaN row, not a
202
+ dropped row -- shape stays [n_nodes, T] regardless of coverage.
203
+ """
204
+ aligned = wide_df.reindex(columns=nodes_df["station_code"])
205
+ return aligned.values.T, list(wide_df.index)
src/graph/node_features.py CHANGED
@@ -131,6 +131,181 @@ def add_idpr_features(nodes_df: pd.DataFrame, idpr_path: Path) -> pd.DataFrame:
131
  return out
132
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  def add_catchment_features(nodes_df: pd.DataFrame, catchment_path: Path) -> pd.DataFrame:
135
  """
136
  Catchment (drainage basin) area per station, in km², via
@@ -344,6 +519,10 @@ def build_node_features(
344
  safran_path: Optional[Path] = None,
345
  hydrometric_path: Optional[Path] = None,
346
  catchment_path: Optional[Path] = None,
 
 
 
 
347
  prefix_map=None,
348
  base_nodes_df: Optional[pd.DataFrame] = None,
349
  date_range: Optional[Tuple[str, str]] = None,
@@ -397,6 +576,10 @@ def build_node_features(
397
  for label, path, fn in [
398
  ("idpr", idpr_path, add_idpr_features),
399
  ("catchment_area", catchment_path, add_catchment_features),
 
 
 
 
400
  ("ades_groundwater", ades_path, add_groundwater_features),
401
  ("safran_climate", safran_path, add_safran_features),
402
  ("hydrometric_targets", hydrometric_path, add_hydrometric_target_stats),
 
131
  return out
132
 
133
 
134
+ def add_geology_features(nodes_df: pd.DataFrame, bdcharm_path: Path) -> pd.DataFrame:
135
+ """
136
+ Geological formation class per node, one-hot encoded, from
137
+ scripts/download_bdcharm.py's per-department BD Charm-50 shapefiles
138
+ (datasets/bdcharm50/dept_{027,028,061}/).
139
+
140
+ One-hot, not a raw formation code -- same reasoning as
141
+ add_landcover_features: geological formation is a nominal category,
142
+ not an ordered quantity, so a raw code would let build_pyg_graph's
143
+ numeric auto-detection z-score it as if formation codes had a
144
+ meaningful numeric ordering, which they don't.
145
+ """
146
+ import geopandas as gpd
147
+ from shapely.geometry import Point
148
+
149
+ base = Path(bdcharm_path)
150
+ shp_files = sorted(base.glob("dept_*/**/*.shp"))
151
+ fgeol_files = [f for f in shp_files if "FGEOL" in f.name.upper()]
152
+ if not fgeol_files:
153
+ raise FileNotFoundError(
154
+ f"No *FGEOL* shapefile found under {base}/dept_*/. Found instead: "
155
+ f"{[f.name for f in shp_files]}. Inspect these directly to find the "
156
+ f"real geological-formations layer if the naming differs from what "
157
+ f"was expected."
158
+ )
159
+ print(f"add_geology_features: using formation layer(s): {[f.name for f in fgeol_files]}")
160
+
161
+ polygons = pd.concat([gpd.read_file(f) for f in fgeol_files], ignore_index=True)
162
+ polygons = gpd.GeoDataFrame(polygons, geometry="geometry")
163
+
164
+ label_candidates = [c for c in polygons.columns if any(
165
+ kw in c.upper() for kw in ("NOTATION", "CODE", "LEGENDE", "LEG", "LITHO", "FORMATION")
166
+ ) and c.upper() != "GEOMETRY"]
167
+ if not label_candidates:
168
+ raise ValueError(
169
+ f"No plausible lithology/formation column found among {list(polygons.columns)}. "
170
+ f"Inspect the real shapefile's attribute table directly and adjust the "
171
+ f"keyword list in add_geology_features."
172
+ )
173
+ label_col = label_candidates[0]
174
+ print(f"add_geology_features: using attribute column {label_col!r} "
175
+ f"(other candidates considered: {label_candidates[1:]})")
176
+
177
+ stations = gpd.GeoDataFrame(
178
+ nodes_df,
179
+ geometry=[Point(lon, lat) for lon, lat in zip(nodes_df["longitude"], nodes_df["latitude"])],
180
+ crs="EPSG:4326",
181
+ )
182
+ if polygons.crs is not None and polygons.crs != stations.crs:
183
+ polygons = polygons.to_crs(stations.crs)
184
+
185
+ joined = gpd.sjoin(stations, polygons[[label_col, "geometry"]], how="left", predicate="within")
186
+ joined = joined.drop_duplicates(subset="station_code" if "station_code" in joined.columns else joined.index.name)
187
+
188
+ n_matched = joined[label_col].notna().sum()
189
+ print(f"add_geology_features: {n_matched}/{len(nodes_df)} nodes matched to a real formation polygon")
190
+
191
+ dummies = pd.get_dummies(joined[label_col], prefix="geology")
192
+ result = pd.concat([nodes_df.reset_index(drop=True), dummies.reset_index(drop=True)], axis=1)
193
+ return result
194
+
195
+
196
+ def add_cavites_features(
197
+ nodes_df: pd.DataFrame, bdcavites_path: Path, max_distance_km: float = 20.0
198
+ ) -> pd.DataFrame:
199
+ """
200
+ Distance to nearest known cavity/sinkhole + count within
201
+ max_distance_km, from scripts/download_bdcavites.py's
202
+ cavite_localisee.geojson (BRGM's national cavity inventory, via
203
+ Géorisques' WFS).
204
+
205
+ Same KD-tree coarse-prefilter + exact-haversine pattern already
206
+ validated for add_groundwater_features -- point data at unknown
207
+ real density, so avoid assuming either a fast-enough naive loop or
208
+ guessing at scale ahead of time.
209
+ """
210
+ import json
211
+ from scipy.spatial import cKDTree
212
+
213
+ path = Path(bdcavites_path)
214
+ if not path.exists():
215
+ raise FileNotFoundError(f"{path} not found")
216
+
217
+ geojson = json.loads(path.read_text())
218
+ features = geojson.get("features", [])
219
+ if not features:
220
+ print(f"add_cavites_features: {path} has 0 features -- "
221
+ f"every station will get distance=NaN, count=0")
222
+ out = nodes_df.copy()
223
+ out["distance_to_nearest_cavity_km"] = float("nan")
224
+ out["n_cavities_within_20km"] = 0
225
+ return out
226
+
227
+ cavity_coords = []
228
+ for f in features:
229
+ geom = f.get("geometry", {})
230
+ coords = geom.get("coordinates")
231
+ if geom.get("type") == "Point" and coords:
232
+ cavity_coords.append((coords[1], coords[0])) # (lat, lon)
233
+ cavity_lat = np.array([c[0] for c in cavity_coords])
234
+ cavity_lon = np.array([c[1] for c in cavity_coords])
235
+ print(f"add_cavites_features: {len(cavity_coords)} point cavity feature(s) loaded from {path}")
236
+
237
+ tree = cKDTree(np.column_stack([cavity_lon, cavity_lat]))
238
+ coarse_radius_deg = (max_distance_km / 111.0) * 1.5 # see add_groundwater_features for rationale
239
+
240
+ station_lon = nodes_df["longitude"].values
241
+ station_lat = nodes_df["latitude"].values
242
+ candidate_lists = tree.query_ball_point(np.column_stack([station_lon, station_lat]), r=coarse_radius_deg)
243
+
244
+ min_dists, counts = [], []
245
+ for i, candidates in enumerate(candidate_lists):
246
+ if not candidates:
247
+ min_dists.append(float("nan"))
248
+ counts.append(0)
249
+ continue
250
+ cand_idx = np.array(candidates)
251
+ dist = _haversine_km_vec(station_lat[i], station_lon[i], cavity_lat[cand_idx], cavity_lon[cand_idx])
252
+ within = dist <= max_distance_km
253
+ min_dists.append(float(dist.min()) if within.any() else float("nan"))
254
+ counts.append(int(within.sum()))
255
+
256
+ out = nodes_df.copy()
257
+ out["distance_to_nearest_cavity_km"] = min_dists
258
+ out[f"n_cavities_within_{int(max_distance_km)}km"] = counts
259
+ return out
260
+
261
+
262
+ def add_ndvi_features(nodes_df: pd.DataFrame, ndvi_path: Path) -> pd.DataFrame:
263
+ """
264
+ ESA WorldCover Sentinel-2 NDVI yearly percentile composite
265
+ (p10/p50/p90) per station, from scripts/fetch_worldcover_ndvi.py.
266
+
267
+ NOTE: raw values are VITO's scaled digital numbers, not independently
268
+ confirmed to be literal -1..1 NDVI (no access to their exact scale/
269
+ offset convention in this environment) -- internally consistent
270
+ (p90 > p50 > p10 holds for every real station checked), so the
271
+ relative signal is trustworthy even if the absolute units aren't
272
+ pinned down. Doesn't block use as a model feature: build_pyg_graph
273
+ z-scores every feature anyway, which is invariant to an unknown
274
+ linear scaling. Only matters if literal NDVI units are needed later
275
+ (e.g. for plotting against a textbook NDVI range) -- resolve the
276
+ scale/offset from VITO's product documentation before then.
277
+
278
+ Same coverage limitation as add_catchment_features/add_landcover_
279
+ features: exact station_code match, real gauges only for now.
280
+ """
281
+ ndvi_df = pd.read_csv(ndvi_path)[["station_code", "ndvi_p10", "ndvi_p50", "ndvi_p90"]]
282
+ return nodes_df.merge(ndvi_df, on="station_code", how="left")
283
+
284
+
285
+ def add_landcover_features(nodes_df: pd.DataFrame, landcover_path: Path) -> pd.DataFrame:
286
+ """
287
+ ESA WorldCover landcover class per station, one-hot encoded --
288
+ NOT left as the raw integer class code (10=Tree cover, 50=Built-up,
289
+ etc.). Landcover is a nominal category, not an ordered quantity;
290
+ leaving it as a raw integer would let build_pyg_graph's numeric
291
+ auto-detection z-score it as if "Built-up" (50) were meaningfully
292
+ "more" than "Tree cover" (10) in some continuous sense, which isn't
293
+ physically true -- the same class of error as the structural-column
294
+ leakage bug found earlier in this project, just subtler since this
295
+ one IS meant to be a real model input, not excluded metadata.
296
+
297
+ From scripts/fetch_worldcover_landcover.py -- currently only
298
+ covers real gauge stations (exact station_code match, same
299
+ limitation as add_catchment_features): non-gauge reach graph nodes
300
+ get NaN/all-zero here until that script is extended to sample the
301
+ full node set, not just the 27 gauges.
302
+ """
303
+ landcover_df = pd.read_csv(landcover_path)[["station_code", "landcover_class_name"]]
304
+ dummies = pd.get_dummies(landcover_df["landcover_class_name"], prefix="landcover")
305
+ landcover_wide = pd.concat([landcover_df[["station_code"]], dummies], axis=1)
306
+ return nodes_df.merge(landcover_wide, on="station_code", how="left")
307
+
308
+
309
  def add_catchment_features(nodes_df: pd.DataFrame, catchment_path: Path) -> pd.DataFrame:
310
  """
311
  Catchment (drainage basin) area per station, in km², via
 
519
  safran_path: Optional[Path] = None,
520
  hydrometric_path: Optional[Path] = None,
521
  catchment_path: Optional[Path] = None,
522
+ landcover_path: Optional[Path] = None,
523
+ ndvi_path: Optional[Path] = None,
524
+ bdcavites_path: Optional[Path] = None,
525
+ bdcharm_path: Optional[Path] = None,
526
  prefix_map=None,
527
  base_nodes_df: Optional[pd.DataFrame] = None,
528
  date_range: Optional[Tuple[str, str]] = None,
 
576
  for label, path, fn in [
577
  ("idpr", idpr_path, add_idpr_features),
578
  ("catchment_area", catchment_path, add_catchment_features),
579
+ ("landcover", landcover_path, add_landcover_features),
580
+ ("ndvi", ndvi_path, add_ndvi_features),
581
+ ("cavites", bdcavites_path, add_cavites_features),
582
+ ("geology", bdcharm_path, add_geology_features),
583
  ("ades_groundwater", ades_path, add_groundwater_features),
584
  ("safran_climate", safran_path, add_safran_features),
585
  ("hydrometric_targets", hydrometric_path, add_hydrometric_target_stats),
src/graph/physics_losses.py CHANGED
@@ -33,9 +33,28 @@ except ImportError:
33
  _HAS_TORCH = False
34
 
35
 
36
- def _mse(residual) -> float:
37
- """Mean squared residual -- works identically on numpy or torch."""
38
- return (residual ** 2).mean()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
  # ---------------------------------------------------------------------------
 
33
  _HAS_TORCH = False
34
 
35
 
36
+ def _mse(residual):
37
+ """
38
+ Mean squared residual, NaN-masked -- works identically on numpy or
39
+ torch. Real ground-truth Q is naturally, heavily NaN (only real
40
+ gauges with real observations ever have a value; confirmed against
41
+ real data: ~93.5% NaN for the reach graph's discharge tensor) --
42
+ without masking, ANY single NaN anywhere in the residual poisons
43
+ the entire mean to NaN, which isn't a rare edge case for this data,
44
+ it's the normal shape of it. Returns NaN only if truly nothing
45
+ usable exists (every entry NaN), which is a real "no data" signal
46
+ worth surfacing, not silently averaging to 0 and implying perfect
47
+ physics satisfaction when there was actually no evidence either way.
48
+ """
49
+ if _HAS_TORCH and isinstance(residual, torch.Tensor):
50
+ mask = ~torch.isnan(residual)
51
+ if not mask.any():
52
+ return residual.sum() * float("nan")
53
+ return (residual[mask] ** 2).mean()
54
+ mask = ~np.isnan(residual)
55
+ if not mask.any():
56
+ return np.nan
57
+ return (residual[mask] ** 2).mean()
58
 
59
 
60
  # ---------------------------------------------------------------------------
src/test_build_graph.py CHANGED
@@ -30,11 +30,15 @@ try:
30
  from .graph.node_features import build_node_features
31
  from .graph.build_graph import build_surface_edges, build_pyg_graph, build_pyg_graphs_per_basin
32
  from .data.river_centerline import load_centerline, snap_gauges_to_centerline
 
33
  except ImportError:
34
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
  from src.graph.node_features import build_node_features
36
  from src.graph.build_graph import build_surface_edges, build_pyg_graph, build_pyg_graphs_per_basin
37
  from src.data.river_centerline import load_centerline, snap_gauges_to_centerline
 
 
 
38
 
39
 
40
  def _to_numpy(t):
@@ -265,13 +269,160 @@ def run_checks(data_root: Path, basin_file_names=None) -> bool:
265
  return all_ok
266
 
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  def main() -> None:
269
  parser = argparse.ArgumentParser(description="Test build_graph.py / node_features.py against real data")
270
  parser.add_argument("--data-root", type=Path, default=Path("datasets"))
271
  args = parser.parse_args()
272
 
273
- ok = run_checks(args.data_root)
274
- sys.exit(0 if ok else 1)
 
275
 
276
 
277
  if __name__ == "__main__":
 
30
  from .graph.node_features import build_node_features
31
  from .graph.build_graph import build_surface_edges, build_pyg_graph, build_pyg_graphs_per_basin
32
  from .data.river_centerline import load_centerline, snap_gauges_to_centerline
33
+ from .graph.physics_losses import build_confluence_index, build_braid_index
34
  except ImportError:
35
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
36
  from src.graph.node_features import build_node_features
37
  from src.graph.build_graph import build_surface_edges, build_pyg_graph, build_pyg_graphs_per_basin
38
  from src.data.river_centerline import load_centerline, snap_gauges_to_centerline
39
+ from src.graph.physics_losses import build_confluence_index, build_braid_index
40
+
41
+ import pandas as pd
42
 
43
 
44
  def _to_numpy(t):
 
269
  return all_ok
270
 
271
 
272
+ def run_reach_graph_checks(data_root: Path, basin_file_names=None) -> bool:
273
+ """
274
+ Validates the reach graph pipeline (scripts/build_reach_graphs.py ->
275
+ scripts/enrich_reach_graph.py -> build_pyg_graph), separately from
276
+ run_checks' coverage of the original single-chain pipeline -- the
277
+ two build nodes_df/edges_df in genuinely different ways (real
278
+ branching topology + splits/rejoins vs. a single ordered chain), so
279
+ keeping their checks in separate functions keeps each readable
280
+ rather than threading conditionals through one long function.
281
+
282
+ Specifically regression-tests the three bugs found and fixed in the
283
+ previous session: structural columns (is_gauged/is_confluence/etc.)
284
+ leaking into model features, targets attaching to non-gauge nodes,
285
+ and edge_attr breaking on the real edges schema's extra string
286
+ columns (toponym, cleabs).
287
+
288
+ Gracefully returns True (not a failure) if the reach graph hasn't
289
+ been built/enriched yet -- this is meant to add coverage once that
290
+ pipeline is in use, not force it to exist.
291
+ """
292
+ basin_file_names = basin_file_names or {0: "eure", 1: "risle"}
293
+ all_ok = True
294
+ graph_dir = data_root / "reach_graph"
295
+
296
+ print()
297
+ print("=" * 60)
298
+ print("REACH GRAPH CHECKS")
299
+ print("=" * 60)
300
+
301
+ if not graph_dir.exists():
302
+ print(f" No {graph_dir} found -- skipping (run scripts/build_reach_graphs.py "
303
+ f"and scripts/enrich_reach_graph.py first for this coverage). Not a failure.")
304
+ return True
305
+
306
+ any_basin_found = False
307
+ for basin_id, file_key in basin_file_names.items():
308
+ enriched_path = graph_dir / f"{file_key}_nodes_enriched.csv"
309
+ edges_path = graph_dir / f"{file_key}_edges.csv"
310
+ if not enriched_path.exists() or not edges_path.exists():
311
+ print(f" basin {basin_id} ({file_key}): missing enriched nodes/edges CSV, skipped")
312
+ continue
313
+ any_basin_found = True
314
+
315
+ nodes_df = pd.read_csv(enriched_path)
316
+ edges_df = pd.read_csv(edges_path)
317
+ print()
318
+ print(f"--- basin {basin_id} ({file_key}): {len(nodes_df)} nodes, {len(edges_df)} edges ---")
319
+
320
+ structural_cols = ["is_gauged", "is_confluence", "is_split_point", "is_rejoin_point"]
321
+ for col in structural_cols:
322
+ all_ok &= _check(f" '{col}' column present on nodes_df", col in nodes_df.columns)
323
+
324
+ n_gauged = int(nodes_df["is_gauged"].sum()) if "is_gauged" in nodes_df.columns else 0
325
+ n_confluence = int(nodes_df["is_confluence"].sum()) if "is_confluence" in nodes_df.columns else 0
326
+ n_split = int(nodes_df["is_split_point"].sum()) if "is_split_point" in nodes_df.columns else 0
327
+ n_rejoin = int(nodes_df["is_rejoin_point"].sum()) if "is_rejoin_point" in nodes_df.columns else 0
328
+ print(f" {n_gauged} gauged, {n_confluence} confluences, {n_split} splits, {n_rejoin} rejoins")
329
+
330
+ data = build_pyg_graph(nodes_df, edges_df)
331
+ x_np = _to_numpy(data.x)
332
+ all_ok &= _check(" x has no NaN", not np.isnan(x_np).any())
333
+ all_ok &= _check(" x has no Inf", not np.isinf(x_np).any())
334
+ all_ok &= _check(" x row count matches node count", x_np.shape[0] == len(nodes_df),
335
+ f"{x_np.shape[0]} vs {len(nodes_df)}")
336
+
337
+ # REGRESSION: structural columns must never leak into model features
338
+ leaked = [f for f in data.feature_names
339
+ if f in ("is_gauged", "is_confluence", "is_split_point", "is_rejoin_point",
340
+ "snap_distance_km", "braid_id")]
341
+ all_ok &= _check(" no structural columns leaked into feature_names", not leaked,
342
+ f"leaked: {leaked}")
343
+
344
+ # REGRESSION: structural columns still accessible as their own Data attributes
345
+ for col in structural_cols:
346
+ all_ok &= _check(f" data.{col} attribute present", hasattr(data, col))
347
+
348
+ # REGRESSION: targets only ever attach to gauged nodes, never confluences/virtual nodes
349
+ if hasattr(data, "y") and hasattr(data, "is_gauged"):
350
+ y_np = _to_numpy(data.y)
351
+ is_gauged_np = _to_numpy(data.is_gauged).astype(bool)
352
+ has_real_target = ~np.isnan(y_np).all(axis=1)
353
+ mislabeled = has_real_target & ~is_gauged_np
354
+ all_ok &= _check(" no target values on non-gauged nodes", not mislabeled.any(),
355
+ f"{int(mislabeled.sum())} mislabeled node(s)")
356
+ all_ok &= _check(" target coverage matches gauge count or less",
357
+ int(has_real_target.sum()) <= n_gauged,
358
+ f"{int(has_real_target.sum())} with targets vs {n_gauged} gauged")
359
+
360
+ # REGRESSION: edge_attr stays 3 columns despite extra edge metadata (toponym, cleabs)
361
+ edge_attr_np = _to_numpy(data.edge_attr)
362
+ all_ok &= _check(" edge_attr has exactly 3 columns despite extra edge metadata",
363
+ edge_attr_np.shape[1] == 3, f"got {edge_attr_np.shape[1]} columns")
364
+
365
+ # Physics loss index builders (physics_losses.py) sanity-checked against this same data
366
+ conf_idx = build_confluence_index(nodes_df, edges_df)
367
+ braid_idx = build_braid_index(nodes_df)
368
+ all_ok &= _check(" confluence_index count matches is_confluence sum",
369
+ len(conf_idx) == n_confluence, f"{len(conf_idx)} vs {n_confluence}")
370
+ if conf_idx:
371
+ max_idx = max(max(upstream) for _, upstream in conf_idx)
372
+ all_ok &= _check(" confluence_index indices within node bounds",
373
+ 0 <= max_idx < len(nodes_df))
374
+ min_upstream = min(len(upstream) for _, upstream in conf_idx)
375
+ all_ok &= _check(" every confluence has >= 2 upstream branches", min_upstream >= 2)
376
+ if braid_idx:
377
+ max_braid_idx = max(max(pair) for pair in braid_idx)
378
+ all_ok &= _check(" braid_index indices within node bounds",
379
+ 0 <= max_braid_idx < len(nodes_df))
380
+ all_ok &= _check(" braid_index count matches is_rejoin_point sum",
381
+ len(braid_idx) == n_rejoin, f"{len(braid_idx)} vs {n_rejoin}")
382
+
383
+ n_climate = int(nodes_df["climate_precip_mm"].notna().sum()) if "climate_precip_mm" in nodes_df.columns else 0
384
+ n_catchment = int(nodes_df["catchment_area_km2"].notna().sum()) if "catchment_area_km2" in nodes_df.columns else 0
385
+ n_idpr = int(nodes_df["idpr_value"].notna().sum()) if "idpr_value" in nodes_df.columns else 0
386
+ print(f" coverage: climate {n_climate}/{len(nodes_df)}, idpr {n_idpr}/{len(nodes_df)}, "
387
+ f"catchment_area (Hub'Eau, gauges only) {n_catchment}/{len(nodes_df)}")
388
+ all_ok &= _check(" idpr present on the enriched table (was missing in a real run once -- "
389
+ "check the idpr_path passed to enrich_reach_graph.py if this fails)",
390
+ "idpr_value" in nodes_df.columns)
391
+
392
+ if "cumulative_catchment_area_km2" in nodes_df.columns:
393
+ n_cumulative = int(nodes_df["cumulative_catchment_area_km2"].notna().sum())
394
+ print(f" cumulative_catchment_area_km2 (BD TOPO, graph-wide) coverage: "
395
+ f"{n_cumulative}/{len(nodes_df)}")
396
+ all_ok &= _check(" cumulative_catchment_area_km2 covers meaningfully more than "
397
+ "the Hub'Eau-only catchment_area_km2",
398
+ n_cumulative > n_catchment,
399
+ f"{n_cumulative} vs {n_catchment} -- if this fails, "
400
+ f"scripts/compute_cumulative_catchment.py likely needs a re-run")
401
+ else:
402
+ print(" cumulative_catchment_area_km2 not present -- run "
403
+ "scripts/compute_cumulative_catchment.py for graph-wide catchment coverage "
404
+ "(known gap otherwise: only the ~8 real gauges Hub'Eau publishes it for).")
405
+
406
+ if not any_basin_found:
407
+ print(" No enriched reach graph files found for any basin -- run "
408
+ "scripts/build_reach_graphs.py then scripts/enrich_reach_graph.py first.")
409
+ return True
410
+
411
+ print()
412
+ print("=" * 60)
413
+ print("REACH GRAPH RESULT:", "ALL CHECKS PASSED" if all_ok else "SOME CHECKS FAILED — see above")
414
+ print("=" * 60)
415
+ return all_ok
416
+
417
+
418
  def main() -> None:
419
  parser = argparse.ArgumentParser(description="Test build_graph.py / node_features.py against real data")
420
  parser.add_argument("--data-root", type=Path, default=Path("datasets"))
421
  args = parser.parse_args()
422
 
423
+ ok_original = run_checks(args.data_root)
424
+ ok_reach_graph = run_reach_graph_checks(args.data_root)
425
+ sys.exit(0 if (ok_original and ok_reach_graph) else 1)
426
 
427
 
428
  if __name__ == "__main__":