File size: 5,403 Bytes
4bb7968
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
"""
Builds genuine [n_nodes, T] dynamic tensors (discharge, groundwater,
climate) for a basin's reach graph and feeds them directly into
physics_losses.py's routing_consistency_loss -- the actual integration
point dynamic_features.py exists for. Without this script, that
function has real inputs it could consume but nothing actually
producing them.

Usage:
    python -m scripts.build_dynamic_tensors --data-root datasets --basin risle
"""
import argparse
from pathlib import Path

import numpy as np
import pandas as pd

try:
    from src.graph.dynamic_features import (
        build_discharge_timeseries, build_groundwater_timeseries,
        build_climate_timeseries, assemble_dynamic_tensor,
    )
    from src.graph.physics_losses import build_routing_index, routing_consistency_loss
except ImportError:
    import sys
    sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
    from src.graph.dynamic_features import (
        build_discharge_timeseries, build_groundwater_timeseries,
        build_climate_timeseries, assemble_dynamic_tensor,
    )
    from src.graph.physics_losses import build_routing_index, routing_consistency_loss

BASIN_FILE_NAMES = {0: "eure", 1: "risle"}


def run_for_basin(data_root: Path, basin_id: int, file_key: str, date_range, skip_climate: bool) -> None:
    graph_dir = data_root / "reach_graph"
    nodes_path = graph_dir / f"{file_key}_nodes_enriched.csv"
    edges_path = graph_dir / f"{file_key}_edges.csv"
    if not nodes_path.exists() or not edges_path.exists():
        print(f"{file_key}: missing enriched nodes/edges -- run build_reach_graphs.py + "
              f"enrich_reach_graph.py first. Skipping.")
        return

    nodes_df = pd.read_csv(nodes_path)
    edges_df = pd.read_csv(edges_path)
    print(f"--- {file_key}: {len(nodes_df)} nodes, {len(edges_df)} edges ---")

    discharge_wide = build_discharge_timeseries(nodes_df, data_root / "hydrometric", date_range)
    Q, dates = assemble_dynamic_tensor(nodes_df, discharge_wide)
    n_real = int((~np.isnan(Q)).sum())
    print(f"discharge tensor: {Q.shape}, {n_real}/{Q.size} real (non-NaN) values "
          f"({100*n_real/Q.size:.3f}% coverage -- expect this to be tiny, only real "
          f"gauges with real observations ever have a value here)")

    level_wide, depth_wide = build_groundwater_timeseries(nodes_df, data_root / "ades", date_range)
    level_tensor, _ = assemble_dynamic_tensor(nodes_df, level_wide)
    depth_tensor, _ = assemble_dynamic_tensor(nodes_df, depth_wide)
    print(f"groundwater level tensor: {level_tensor.shape}, "
          f"{int((~np.isnan(level_tensor)).sum())} real values")

    climate_tensors = {}
    if not skip_climate and (data_root / "safran").exists():
        try:
            climate_dict = build_climate_timeseries(nodes_df, data_root / "safran", date_range)
            for var, wide in climate_dict.items():
                tensor, _ = assemble_dynamic_tensor(nodes_df, wide)
                climate_tensors[var] = tensor
            print(f"climate variables: {list(climate_tensors.keys())}")
        except Exception as e:
            print(f"climate skipped (error: {e})")

    out_dir = graph_dir / "dynamic"
    out_dir.mkdir(exist_ok=True)
    save_kwargs = {
        "discharge": Q, "groundwater_level": level_tensor, "groundwater_depth": depth_tensor,
        "dates": np.array([str(d) for d in dates]), "station_codes": nodes_df["station_code"].values,
    }
    save_kwargs.update({f"climate_{k}": v for k, v in climate_tensors.items()})
    out_path = out_dir / f"{file_key}_dynamic.npz"
    np.savez(out_path, **save_kwargs)
    print(f"saved to {out_path}")

    # The actual integration: routing_consistency_loss needs Q + routing_index together.
    routing_index = build_routing_index(nodes_df, edges_df, timestep_hours=24.0)
    print(f"routing_index: {len(routing_index)} edge(s) with a usable lag at this timestep")

    loss = routing_consistency_loss(Q, routing_index)
    print(f"routing_consistency_loss on the REAL discharge tensor: {loss}")
    if np.isnan(loss):
        print("  ^ NaN. This is exactly the thing worth checking before assuming this loss "
              "is usable: real Q is almost entirely NaN (only real gauges with real "
              "observations have values), and if routing_consistency_loss doesn't mask "
              "NaN out of its residuals before averaging, one missing value anywhere "
              "poisons the entire loss to NaN. See whether this fired.")
    print()


def main() -> None:
    parser = argparse.ArgumentParser(description="Build dynamic tensors and wire into physics_losses.py")
    parser.add_argument("--data-root", type=Path, default=Path("datasets"))
    parser.add_argument("--basin", choices=["eure", "risle", "both"], default="both")
    parser.add_argument("--start-date", type=str, default="2013-01-01")
    parser.add_argument("--end-date", type=str, default="2026-12-31")
    parser.add_argument("--skip-climate", action="store_true")
    args = parser.parse_args()

    date_range = (args.start_date, args.end_date)
    basins = BASIN_FILE_NAMES.items() if args.basin == "both" else \
        [(k, v) for k, v in BASIN_FILE_NAMES.items() if v == args.basin]

    for basin_id, file_key in basins:
        run_for_basin(args.data_root, basin_id, file_key, date_range, args.skip_climate)


if __name__ == "__main__":
    main()