File size: 9,442 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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""
Builds genuine [n_nodes, T] time series for the features that
node_features.py's add_safran_features/add_groundwater_features/
add_hydrometric_target_stats only ever hand over as a single period-
aggregated number. That aggregation was always a real, named
limitation (see build_graph.py's DYNAMIC_PREFIXES docstring and the
README's §2.3) -- this module is what actually closes it.

physics_losses.py's routing_consistency_loss specifically needs a real
time dimension and had nothing to consume before this existed.

Three sources, three different real challenges:
  - discharge: already daily, already per-station -- just needs
    pivoting to wide form, restricted to real gauges (no fabrication
    for non-gauge nodes).
  - groundwater: wells report on wildly irregular schedules (confirmed
    against real data: 13 different "latest dates" among 18 wells
    within 20km of one station). Needs resampling to a common daily
    grid via forward-fill (a well's level changes slowly -- carrying
    the last known reading forward is standard practice for sparse
    level data, not an invented shortcut) BEFORE the spatial average,
    not after -- averaging raw irregular readings per exact calendar
    date is exactly the bug that made the old "aggregate_to_stations"
    path undercount real well coverage by 5-10x (see node_features.py's
    add_groundwater_features docstring).
  - climate: SAFRANLoader.load() already returns per-date rows before
    node_features.py's add_safran_features aggregates them -- this
    just skips that aggregation step and pivots instead. UNTESTED in
    this environment: no real ERA5/safran files were available here to
    validate against (only real ADES and hydrometric data were).
"""
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import numpy as np
import pandas as pd


def build_discharge_timeseries(
    nodes_df: pd.DataFrame, hydrometric_path: Path, date_range: Tuple[str, str],
    freq: str = "D",
) -> pd.DataFrame:
    """
    Real daily QmnJ discharge, pivoted to [date x station_code] wide
    form, restricted to the date range and reindexed onto a regular
    daily grid (missing days stay NaN -- no fabrication).

    Deliberately does NOT forward-fill discharge the way groundwater
    gets resampled below: discharge genuinely changes day to day and a
    missing daily reading should stay missing, not be papered over with
    the previous day's value the way a slowly-changing water table can
    reasonably be.

    Only real gauge station_codes appear as columns -- confluences and
    virtual nodes never had discharge observations to begin with, same
    principle as the period-aggregate target_* columns.
    """
    from ..data.loaders.hydrometric import HydrometricLoader

    loader = HydrometricLoader(data_path=hydrometric_path)
    df = loader.load()
    if "discharge_m3s" not in df.columns:
        raise ValueError("Loaded hydrometric data has no discharge_m3s column")

    start, end = date_range
    df = df[(df["date"] >= start) & (df["date"] <= end)]
    wide = df.pivot_table(index="date", columns="station_code", values="discharge_m3s", aggfunc="mean")

    full_index = pd.date_range(start, end, freq=freq)
    wide = wide.reindex(full_index)
    wide.index.name = "date"
    return wide


def build_groundwater_timeseries(
    nodes_df: pd.DataFrame, ades_path: Path, date_range: Tuple[str, str],
    max_distance_km: float = 20.0, freq: str = "D",
) -> Tuple[pd.DataFrame, pd.DataFrame]:
    """
    Real groundwater level, resampled to a daily grid per well
    (forward-fill) BEFORE spatial averaging, then averaged per node
    using the SAME nearby-well set every day -- the spatial join
    (which wells are near which node) is time-invariant, so it's
    computed once, not repeated per date. That's what keeps this
    tractable at real reach-graph scale: O(n_nodes) spatial lookups
    total, not O(n_nodes x n_dates).

    Returns:
        (level_wide, depth_wide) -- both [date x station_code], node
        ordering matching nodes_df. A node with zero wells within
        max_distance_km gets NaN for every date, not zero -- "no
        nearby monitoring" is meaningfully different from "measured
        zero," same principle as node_features.py's static version.
    """
    from ..data.loaders.ades import ADESLoader
    from scipy.spatial import cKDTree

    loader = ADESLoader(data_path=ades_path)
    gw_df = loader.load()
    start, end = date_range
    full_index = pd.date_range(start, end, freq=freq)

    # Resample each well independently to the common daily grid, forward-fill.
    wells_wide = gw_df.pivot_table(index="date", columns="code_bss", values="groundwater_level_m", aggfunc="mean")
    wells_wide = wells_wide.reindex(wells_wide.index.union(full_index)).sort_index().ffill()
    wells_wide = wells_wide.reindex(full_index)

    depth_wide_raw = gw_df.pivot_table(index="date", columns="code_bss", values="groundwater_depth_m", aggfunc="mean") \
        if "groundwater_depth_m" in gw_df.columns else None
    if depth_wide_raw is not None:
        depth_wide_raw = depth_wide_raw.reindex(depth_wide_raw.index.union(full_index)).sort_index().ffill()
        depth_wide_raw = depth_wide_raw.reindex(full_index)

    well_coords = gw_df.drop_duplicates("code_bss").set_index("code_bss")[["lat", "lon"]]
    well_coords = well_coords.reindex(wells_wide.columns)  # align to the pivoted columns' order
    tree = cKDTree(well_coords[["lon", "lat"]].values)
    coarse_radius_deg = (max_distance_km / 111.0) * 1.5

    def _haversine_km(lat1, lon1, lat2, lon2):
        R = 6371.0
        lat1r, lon1r, lat2r, lon2r = map(np.radians, [lat1, lon1, lat2, lon2])
        dlat, dlon = lat2r - lat1r, lon2r - lon1r
        a = np.sin(dlat / 2) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2) ** 2
        return R * 2 * np.arcsin(np.sqrt(a))

    station_lon = nodes_df["longitude"].values
    station_lat = nodes_df["latitude"].values
    candidate_lists = tree.query_ball_point(np.column_stack([station_lon, station_lat]), r=coarse_radius_deg)

    level_cols, depth_cols = {}, {}
    for i, station_code in enumerate(nodes_df["station_code"]):
        candidates = candidate_lists[i]
        if not candidates:
            level_cols[station_code] = pd.Series(np.nan, index=full_index)
            depth_cols[station_code] = pd.Series(np.nan, index=full_index)
            continue
        cand_idx = np.array(candidates)
        cand_lat = well_coords["lat"].values[cand_idx]
        cand_lon = well_coords["lon"].values[cand_idx]
        dist = _haversine_km(station_lat[i], station_lon[i], cand_lat, cand_lon)
        within = cand_idx[dist <= max_distance_km]
        if len(within) == 0:
            level_cols[station_code] = pd.Series(np.nan, index=full_index)
            depth_cols[station_code] = pd.Series(np.nan, index=full_index)
            continue
        well_names = wells_wide.columns[within]
        level_cols[station_code] = wells_wide[well_names].mean(axis=1)
        if depth_wide_raw is not None:
            depth_cols[station_code] = depth_wide_raw[well_names].mean(axis=1)
        else:
            depth_cols[station_code] = pd.Series(np.nan, index=full_index)

    level_wide = pd.DataFrame(level_cols)
    depth_wide = pd.DataFrame(depth_cols)
    return level_wide, depth_wide


def build_climate_timeseries(
    nodes_df: pd.DataFrame, safran_path: Path, date_range: Tuple[str, str],
) -> Dict[str, pd.DataFrame]:
    """
    Real per-date ERA5 variables, pivoted per variable to [date x
    station_code] wide form -- skips node_features.py's add_safran_
    features aggregation step entirely rather than reversing it.

    UNTESTED in this environment: no real era5_*.nc files were
    available here (only ADES and hydrometric real data were). The
    logic mirrors build_discharge_timeseries's pivot pattern directly,
    but please verify the real output shape/values before trusting it
    for training.
    """
    from ..data.loaders.safran import SAFRANLoader

    station_coords = nodes_df.rename(columns={"latitude": "lat", "longitude": "lon"})[
        ["station_code", "lat", "lon"]
    ]
    loader = SAFRANLoader(data_path=safran_path, station_coords=station_coords)
    df = loader.load()
    df = loader.convert_units(df)

    start, end = date_range
    df = df[(df["date"] >= start) & (df["date"] <= end)]

    var_cols = [c for c in df.columns if c not in ("date", "station_code")]
    result = {}
    for var in var_cols:
        wide = df.pivot_table(index="date", columns="station_code", values=var, aggfunc="mean")
        result[var] = wide
    return result


def assemble_dynamic_tensor(nodes_df: pd.DataFrame, wide_df: pd.DataFrame) -> Tuple[np.ndarray, List[pd.Timestamp]]:
    """
    Aligns a [date x station_code] wide DataFrame onto nodes_df's own
    row order, so the result matches data.x's node indexing exactly --
    this is the piece that makes a build_*_timeseries output directly
    usable as physics_losses.py's routing_consistency_loss's `Q`
    argument (shape [n_nodes, T]).

    A node whose station_code never appears as a column (every
    confluence/virtual node, for discharge) gets an all-NaN row, not a
    dropped row -- shape stays [n_nodes, T] regardless of coverage.
    """
    aligned = wide_df.reindex(columns=nodes_df["station_code"])
    return aligned.values.T, list(wide_df.index)