River_Network / src /test_build_graph.py
ageraustine's picture
Upload folder using huggingface_hub (part 2)
4bb7968 verified
Raw
History Blame Contribute Delete
21.5 kB
"""
Test / validation script for src/graph/build_graph.py and
src/graph/node_features.py — runs the full pipeline against real data
under --data-root and checks the result is actually sane (no NaN/Inf,
correct shapes, no accidental cross-basin edges, targets kept separate
from inputs, etc.) rather than just "it didn't crash".
Usage:
python test_build_graph.py --data-root datasets
Expected layout under DATASETS_DIR (any missing piece is skipped, same
as generate_plots.py):
station_elevations.csv
idpr.csv
ades/groundwater_levels_watershed.csv
ades/groundwater_stations.csv
safran/era5_*.nc
hydrometric/discharge_observations.csv
hydrometric/waterlevel_observations.csv
centerlines/eure_centerline.csv
centerlines/risle_centerline.csv
"""
import argparse
import sys
from pathlib import Path
import numpy as np
try:
from .graph.node_features import build_node_features
from .graph.build_graph import build_surface_edges, build_pyg_graph, build_pyg_graphs_per_basin
from .data.river_centerline import load_centerline, snap_gauges_to_centerline
from .graph.physics_losses import build_confluence_index, build_braid_index
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.graph.node_features import build_node_features
from src.graph.build_graph import build_surface_edges, build_pyg_graph, build_pyg_graphs_per_basin
from src.data.river_centerline import load_centerline, snap_gauges_to_centerline
from src.graph.physics_losses import build_confluence_index, build_braid_index
import pandas as pd
def _to_numpy(t):
"""
Convert a torch tensor (or the plain-numpy stub used when this
script is tested without torch installed) to a numpy array.
NOTE: `hasattr(t, "data")` is NOT a safe way to distinguish "real
torch tensor" from "something else" — every real torch tensor has
a `.data` attribute (it returns a Tensor, not a numpy array), so
that check silently does nothing on a real install and lets a live
Tensor leak through. `.detach()` is a better discriminator here:
real tensors have it, plain numpy/stub objects don't.
"""
if hasattr(t, "detach"):
return t.detach().cpu().numpy()
if hasattr(t, "numpy"):
return t.numpy()
return np.asarray(t)
def _check(label: str, condition, detail: str = "") -> bool:
"""Print PASS/FAIL and return a genuine Python bool. `condition` may be
a plain bool, numpy bool_, or (if a torch boolean expression leaked
through) a tensor — bool() forces it to a real Python bool either way,
so a stray tensor can never contaminate the running `all_ok` total."""
condition = bool(condition)
status = "PASS" if condition else "FAIL"
print(f" [{status}] {label}" + (f" — {detail}" if detail and not condition else ""))
return condition
def run_checks(data_root: Path, basin_file_names=None) -> bool:
basin_file_names = basin_file_names or {0: "eure", 1: "risle"}
all_ok = True
print("=" * 60)
print("STEP 1: build_node_features")
print("=" * 60)
enriched, feat_report = build_node_features(
station_elevations_path=data_root / "station_elevations.csv",
idpr_path=data_root / "idpr.csv",
ades_path=data_root / "ades",
safran_path=data_root / "safran",
hydrometric_path=data_root / "hydrometric",
)
print(feat_report)
all_ok &= _check("base station table is non-empty", len(enriched) > 0)
all_ok &= _check("no duplicate station_code", enriched["station_code"].is_unique)
print()
print("=" * 60)
print("STEP 2: build_surface_edges")
print("=" * 60)
base_nodes, edges_df, graph_report = build_surface_edges(
enriched, centerline_dir=data_root / "centerlines", basin_file_names=basin_file_names,
)
print(graph_report)
all_ok &= _check("nodes returned == nodes fed in (no silent drops)",
len(base_nodes) == len(enriched),
f"{len(base_nodes)} vs {len(enriched)}")
all_ok &= _check("every edge endpoint exists in nodes",
set(edges_df["source"]).union(edges_df["target"]).issubset(set(base_nodes["station_code"])))
cross_basin = edges_df.merge(
base_nodes[["station_code", "basin_id"]].rename(columns={"station_code": "source", "basin_id": "src_basin"}),
on="source",
).merge(
base_nodes[["station_code", "basin_id"]].rename(columns={"station_code": "target", "basin_id": "tgt_basin"}),
on="target",
)
all_ok &= _check("no cross-basin edges (surface connectivity only, as intended)",
(cross_basin["src_basin"] == cross_basin["tgt_basin"]).all())
print()
print("=" * 60)
print("STEP 3: merge enriched features back onto graph-ordered nodes")
print("=" * 60)
full_nodes = base_nodes.merge(
enriched.drop(columns=["basin_id", "latitude", "longitude", "elevation_m"]),
on="station_code", how="left",
)
all_ok &= _check("merge preserved row count", len(full_nodes) == len(base_nodes))
print()
print("=" * 60)
print("STEP 4: build_pyg_graph (directed)")
print("=" * 60)
data = build_pyg_graph(full_nodes, edges_df)
print(data)
print("feature_names:", data.feature_names)
print("target_names:", getattr(data, "target_names", None))
x_np = _to_numpy(data.x)
all_ok &= _check("x has no NaN", not np.isnan(x_np).any())
all_ok &= _check("x has no Inf", not np.isinf(x_np).any())
all_ok &= _check("x row count matches node count", x_np.shape[0] == len(full_nodes))
all_ok &= _check("target_* columns excluded from feature_names",
not any(c.startswith("target_") for c in data.feature_names))
# Confirm standardization actually happened: for any feature that isn't
# constant (std > 0 in the original data) and isn't a __was_missing flag,
# its standardized column should have ~0 mean and ~1 std. A feature that
# silently stayed in raw units (e.g. climate_solar_Wm2 in the hundreds
# sitting next to a z-scored elevation near 0) would dominate distance-
# based GNN layers purely by scale, not by actual signal.
non_flag_idx = [i for i, name in enumerate(data.feature_names) if not name.endswith("__was_missing")]
means = x_np[:, non_flag_idx].mean(axis=0)
# ddof=1 (sample std) to match pandas' .std() default, which is what
# build_pyg_graph actually standardizes with — comparing against numpy's
# default ddof=0 (population std) would show a spurious mismatch for
# small station counts (sqrt((n-1)/n) off from 1.0), not a real bug.
stds = x_np[:, non_flag_idx].std(axis=0, ddof=1)
non_constant = stds > 1e-6
all_ok &= _check(
"standardized features have ~0 mean / ~1 std (feature normalization is active)",
bool(np.allclose(means[non_constant], 0, atol=1e-3) and np.allclose(stds[non_constant], 1, atol=1e-3)),
f"mean range [{means[non_constant].min():.3f}, {means[non_constant].max():.3f}], "
f"std range [{stds[non_constant].min():.3f}, {stds[non_constant].max():.3f}]"
if non_constant.any() else "no non-constant features to check",
)
ei_np = _to_numpy(data.edge_index)
all_ok &= _check("edge_index shape is (2, n_edges)",
ei_np.shape == (2, len(edges_df)), f"got {ei_np.shape}")
all_ok &= _check("edge_index values are valid node indices",
bool((ei_np >= 0).all()) and bool((ei_np < len(full_nodes)).all()))
if hasattr(data, "y"):
y_np = _to_numpy(data.y)
all_ok &= _check("y row count matches node count", y_np.shape[0] == len(full_nodes))
all_ok &= _check("y column count matches target_names", y_np.shape[1] == len(data.target_names))
print()
print("=" * 60)
print("STEP 5: build_pyg_graph (bidirectional)")
print("=" * 60)
data_bidir = build_pyg_graph(full_nodes, edges_df, bidirectional=True)
ei_bidir = _to_numpy(data_bidir.edge_index)
all_ok &= _check("bidirectional edge_index is exactly 2x directed",
ei_bidir.shape[1] == 2 * len(edges_df), f"got {ei_bidir.shape[1]}")
print()
print("=" * 60)
print("STEP 6: build_pyg_graphs_per_basin (Eure and Risle as SEPARATE graphs)")
print("=" * 60)
graphs = build_pyg_graphs_per_basin(full_nodes, edges_df)
all_ok &= _check("exactly one graph per basin present in nodes_df",
set(graphs.keys()) == set(full_nodes["basin_id"].unique()),
f"got basins {sorted(graphs.keys())}")
all_ok &= _check("returned more than one graph (not accidentally merged)", len(graphs) > 1)
total_nodes_across_graphs = 0
for basin_id, g in graphs.items():
print(f" basin {basin_id}: {g}")
g_x = _to_numpy(g.x)
g_ei = _to_numpy(g.edge_index)
total_nodes_across_graphs += g_x.shape[0]
all_ok &= _check(f" basin {basin_id}: basin_id excluded from its own feature set",
"basin_id" not in g.feature_names)
all_ok &= _check(f" basin {basin_id}: edge_index uses local 0..n-1 indexing",
bool((g_ei >= 0).all()) and bool((g_ei < g_x.shape[0]).all()),
f"n_nodes={g_x.shape[0]}, edge_index range [{g_ei.min()}, {g_ei.max()}]")
all_ok &= _check(f" basin {basin_id}: x has no NaN", not np.isnan(g_x).any())
all_ok &= _check("node counts across per-basin graphs sum to the combined total",
total_nodes_across_graphs == len(full_nodes),
f"{total_nodes_across_graphs} vs {len(full_nodes)}")
print()
print("=" * 60)
print("STEP 7: known_losing_reaches flagging (smoke test, using first edge)")
print("=" * 60)
if len(edges_df) > 0:
first_edge = (edges_df.iloc[0]["source"], edges_df.iloc[0]["target"])
_, flagged_edges, flagged_report = build_surface_edges(
enriched, centerline_dir=data_root / "centerlines", basin_file_names=basin_file_names,
known_losing_reaches=[first_edge],
)
print(flagged_report)
flagged_row = flagged_edges[
(flagged_edges["source"] == first_edge[0]) & (flagged_edges["target"] == first_edge[1])
]
all_ok &= _check("flagged edge has verified_continuous=False",
bool((~flagged_row["verified_continuous"]).iloc[0]))
else:
print(" (skipped: no edges to test with)")
print()
print("=" * 60)
print("STEP 8: centerline accuracy sanity check (snap_distance_km)")
print("=" * 60)
# A real BD TOPO/BD TOPAGE-sourced centerline should put gauges within
# ~sub-km of the mapped line. A stale/approximate centerline (e.g. one
# still digitized from a road-map screenshot, ~4km georeferencing
# error) shows up here as a much larger mean snap distance -- this
# check exists specifically to catch "one basin got upgraded to real
# geometry and the other was silently left on the old approximate
# version" without anyone having to eyeball it.
MEAN_SNAP_DISTANCE_WARN_KM = 2.0
centerline_dir = data_root / "centerlines"
if not centerline_dir.exists():
print(" (skipped: no centerlines/ directory found)")
else:
for basin_id, file_key in basin_file_names.items():
csv_path = centerline_dir / f"{file_key}_centerline.csv"
if not csv_path.exists():
print(f" basin {basin_id} ({file_key}): no centerline file, skipped")
continue
cl = load_centerline(csv_path)
b_nodes = full_nodes[full_nodes["basin_id"] == basin_id]
if b_nodes.empty:
continue
snapped = snap_gauges_to_centerline(cl, b_nodes)
mean_dist = snapped["snap_distance_km"].mean()
max_dist = snapped["snap_distance_km"].max()
print(f" basin {basin_id} ({file_key}): {len(cl)} centerline points, "
f"mean snap_distance={mean_dist:.3f} km, max={max_dist:.3f} km")
all_ok &= _check(
f" basin {basin_id} ({file_key}): mean snap distance < {MEAN_SNAP_DISTANCE_WARN_KM} km "
f"(a much larger value usually means this basin is still on an approximate/stale centerline)",
mean_dist < MEAN_SNAP_DISTANCE_WARN_KM,
f"mean={mean_dist:.3f} km",
)
print()
print("=" * 60)
print("RESULT:", "ALL CHECKS PASSED" if all_ok else "SOME CHECKS FAILED — see above")
print("=" * 60)
return all_ok
def run_reach_graph_checks(data_root: Path, basin_file_names=None) -> bool:
"""
Validates the reach graph pipeline (scripts/build_reach_graphs.py ->
scripts/enrich_reach_graph.py -> build_pyg_graph), separately from
run_checks' coverage of the original single-chain pipeline -- the
two build nodes_df/edges_df in genuinely different ways (real
branching topology + splits/rejoins vs. a single ordered chain), so
keeping their checks in separate functions keeps each readable
rather than threading conditionals through one long function.
Specifically regression-tests the three bugs found and fixed in the
previous session: structural columns (is_gauged/is_confluence/etc.)
leaking into model features, targets attaching to non-gauge nodes,
and edge_attr breaking on the real edges schema's extra string
columns (toponym, cleabs).
Gracefully returns True (not a failure) if the reach graph hasn't
been built/enriched yet -- this is meant to add coverage once that
pipeline is in use, not force it to exist.
"""
basin_file_names = basin_file_names or {0: "eure", 1: "risle"}
all_ok = True
graph_dir = data_root / "reach_graph"
print()
print("=" * 60)
print("REACH GRAPH CHECKS")
print("=" * 60)
if not graph_dir.exists():
print(f" No {graph_dir} found -- skipping (run scripts/build_reach_graphs.py "
f"and scripts/enrich_reach_graph.py first for this coverage). Not a failure.")
return True
any_basin_found = False
for basin_id, file_key in basin_file_names.items():
enriched_path = graph_dir / f"{file_key}_nodes_enriched.csv"
edges_path = graph_dir / f"{file_key}_edges.csv"
if not enriched_path.exists() or not edges_path.exists():
print(f" basin {basin_id} ({file_key}): missing enriched nodes/edges CSV, skipped")
continue
any_basin_found = True
nodes_df = pd.read_csv(enriched_path)
edges_df = pd.read_csv(edges_path)
print()
print(f"--- basin {basin_id} ({file_key}): {len(nodes_df)} nodes, {len(edges_df)} edges ---")
structural_cols = ["is_gauged", "is_confluence", "is_split_point", "is_rejoin_point"]
for col in structural_cols:
all_ok &= _check(f" '{col}' column present on nodes_df", col in nodes_df.columns)
n_gauged = int(nodes_df["is_gauged"].sum()) if "is_gauged" in nodes_df.columns else 0
n_confluence = int(nodes_df["is_confluence"].sum()) if "is_confluence" in nodes_df.columns else 0
n_split = int(nodes_df["is_split_point"].sum()) if "is_split_point" in nodes_df.columns else 0
n_rejoin = int(nodes_df["is_rejoin_point"].sum()) if "is_rejoin_point" in nodes_df.columns else 0
print(f" {n_gauged} gauged, {n_confluence} confluences, {n_split} splits, {n_rejoin} rejoins")
data = build_pyg_graph(nodes_df, edges_df)
x_np = _to_numpy(data.x)
all_ok &= _check(" x has no NaN", not np.isnan(x_np).any())
all_ok &= _check(" x has no Inf", not np.isinf(x_np).any())
all_ok &= _check(" x row count matches node count", x_np.shape[0] == len(nodes_df),
f"{x_np.shape[0]} vs {len(nodes_df)}")
# REGRESSION: structural columns must never leak into model features
leaked = [f for f in data.feature_names
if f in ("is_gauged", "is_confluence", "is_split_point", "is_rejoin_point",
"snap_distance_km", "braid_id")]
all_ok &= _check(" no structural columns leaked into feature_names", not leaked,
f"leaked: {leaked}")
# REGRESSION: structural columns still accessible as their own Data attributes
for col in structural_cols:
all_ok &= _check(f" data.{col} attribute present", hasattr(data, col))
# REGRESSION: targets only ever attach to gauged nodes, never confluences/virtual nodes
if hasattr(data, "y") and hasattr(data, "is_gauged"):
y_np = _to_numpy(data.y)
is_gauged_np = _to_numpy(data.is_gauged).astype(bool)
has_real_target = ~np.isnan(y_np).all(axis=1)
mislabeled = has_real_target & ~is_gauged_np
all_ok &= _check(" no target values on non-gauged nodes", not mislabeled.any(),
f"{int(mislabeled.sum())} mislabeled node(s)")
all_ok &= _check(" target coverage matches gauge count or less",
int(has_real_target.sum()) <= n_gauged,
f"{int(has_real_target.sum())} with targets vs {n_gauged} gauged")
# REGRESSION: edge_attr stays 3 columns despite extra edge metadata (toponym, cleabs)
edge_attr_np = _to_numpy(data.edge_attr)
all_ok &= _check(" edge_attr has exactly 3 columns despite extra edge metadata",
edge_attr_np.shape[1] == 3, f"got {edge_attr_np.shape[1]} columns")
# Physics loss index builders (physics_losses.py) sanity-checked against this same data
conf_idx = build_confluence_index(nodes_df, edges_df)
braid_idx = build_braid_index(nodes_df)
all_ok &= _check(" confluence_index count matches is_confluence sum",
len(conf_idx) == n_confluence, f"{len(conf_idx)} vs {n_confluence}")
if conf_idx:
max_idx = max(max(upstream) for _, upstream in conf_idx)
all_ok &= _check(" confluence_index indices within node bounds",
0 <= max_idx < len(nodes_df))
min_upstream = min(len(upstream) for _, upstream in conf_idx)
all_ok &= _check(" every confluence has >= 2 upstream branches", min_upstream >= 2)
if braid_idx:
max_braid_idx = max(max(pair) for pair in braid_idx)
all_ok &= _check(" braid_index indices within node bounds",
0 <= max_braid_idx < len(nodes_df))
all_ok &= _check(" braid_index count matches is_rejoin_point sum",
len(braid_idx) == n_rejoin, f"{len(braid_idx)} vs {n_rejoin}")
n_climate = int(nodes_df["climate_precip_mm"].notna().sum()) if "climate_precip_mm" in nodes_df.columns else 0
n_catchment = int(nodes_df["catchment_area_km2"].notna().sum()) if "catchment_area_km2" in nodes_df.columns else 0
n_idpr = int(nodes_df["idpr_value"].notna().sum()) if "idpr_value" in nodes_df.columns else 0
print(f" coverage: climate {n_climate}/{len(nodes_df)}, idpr {n_idpr}/{len(nodes_df)}, "
f"catchment_area (Hub'Eau, gauges only) {n_catchment}/{len(nodes_df)}")
all_ok &= _check(" idpr present on the enriched table (was missing in a real run once -- "
"check the idpr_path passed to enrich_reach_graph.py if this fails)",
"idpr_value" in nodes_df.columns)
if "cumulative_catchment_area_km2" in nodes_df.columns:
n_cumulative = int(nodes_df["cumulative_catchment_area_km2"].notna().sum())
print(f" cumulative_catchment_area_km2 (BD TOPO, graph-wide) coverage: "
f"{n_cumulative}/{len(nodes_df)}")
all_ok &= _check(" cumulative_catchment_area_km2 covers meaningfully more than "
"the Hub'Eau-only catchment_area_km2",
n_cumulative > n_catchment,
f"{n_cumulative} vs {n_catchment} -- if this fails, "
f"scripts/compute_cumulative_catchment.py likely needs a re-run")
else:
print(" cumulative_catchment_area_km2 not present -- run "
"scripts/compute_cumulative_catchment.py for graph-wide catchment coverage "
"(known gap otherwise: only the ~8 real gauges Hub'Eau publishes it for).")
if not any_basin_found:
print(" No enriched reach graph files found for any basin -- run "
"scripts/build_reach_graphs.py then scripts/enrich_reach_graph.py first.")
return True
print()
print("=" * 60)
print("REACH GRAPH RESULT:", "ALL CHECKS PASSED" if all_ok else "SOME CHECKS FAILED — see above")
print("=" * 60)
return all_ok
def main() -> None:
parser = argparse.ArgumentParser(description="Test build_graph.py / node_features.py against real data")
parser.add_argument("--data-root", type=Path, default=Path("datasets"))
args = parser.parse_args()
ok_original = run_checks(args.data_root)
ok_reach_graph = run_reach_graph_checks(args.data_root)
sys.exit(0 if (ok_original and ok_reach_graph) else 1)
if __name__ == "__main__":
main()