Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub (part 2)
Browse files- scripts/__pycache__/fetch_idpr_brgm.cpython-311.pyc +0 -0
- scripts/__pycache__/fetch_landcover.cpython-311.pyc +0 -0
- scripts/__pycache__/fetch_worldcover_ndvi.cpython-311.pyc +0 -0
- scripts/build_dynamic_tensors.py +117 -0
- scripts/download_bdcavites.py +146 -0
- scripts/download_bdcharm.py +107 -0
- scripts/fetch_idpr_brgm.py +144 -0
- scripts/fetch_landcover.py +134 -0
- scripts/fetch_worldcover_ndvi.py +155 -0
- src/graph/build_graph.py +38 -0
- src/graph/dynamic_features.py +205 -0
- src/graph/node_features.py +183 -0
- src/graph/physics_losses.py +22 -3
- src/test_build_graph.py +153 -2
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)
|
| 37 |
-
"""
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 274 |
-
|
|
|
|
| 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__":
|