File size: 16,002 Bytes
d6b0b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4bb7968
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6b0b7a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
"""
Physics-informed loss terms for streamflow prediction on the reach graph.

Four constraints, each tied to real structure the graph now actually
has (see src/graph/build_reach_graph.py):

  1. confluence_mass_balance_loss  -- new mass genuinely enters (is_confluence)
  2. split_rejoin_conservation_loss -- no new mass, paired via braid_id
  3. routing_consistency_loss       -- travel-time lag from distance_km/elevation_drop_m
  4. water_balance_loss             -- P - ET - Q - deltaS ~= 0 per node

All four apply graph-wide, not just at the 27 gauged nodes -- that's the
actual mechanism by which sparse labels generalize to ~4,500 ungauged
nodes, not an incidental detail.

No model exists yet, so these are standalone functions operating on
whatever Q tensor a model eventually produces. They use only operations
that behave identically on a torch.Tensor or a plain numpy array
(indexing, elementwise arithmetic, sum, mean) so the same code path is
testable now with numpy and will work unchanged with real torch tensors
and real gradients once a model exists -- verified by testing this
module against numpy inputs directly.
"""
from typing import Dict, List, Optional, Tuple

import numpy as np
import pandas as pd

try:
    import torch
    _HAS_TORCH = True
except ImportError:
    _HAS_TORCH = False


def _mse(residual):
    """
    Mean squared residual, NaN-masked -- works identically on numpy or
    torch. Real ground-truth Q is naturally, heavily NaN (only real
    gauges with real observations ever have a value; confirmed against
    real data: ~93.5% NaN for the reach graph's discharge tensor) --
    without masking, ANY single NaN anywhere in the residual poisons
    the entire mean to NaN, which isn't a rare edge case for this data,
    it's the normal shape of it. Returns NaN only if truly nothing
    usable exists (every entry NaN), which is a real "no data" signal
    worth surfacing, not silently averaging to 0 and implying perfect
    physics satisfaction when there was actually no evidence either way.
    """
    if _HAS_TORCH and isinstance(residual, torch.Tensor):
        mask = ~torch.isnan(residual)
        if not mask.any():
            return residual.sum() * float("nan")
        return (residual[mask] ** 2).mean()
    mask = ~np.isnan(residual)
    if not mask.any():
        return np.nan
    return (residual[mask] ** 2).mean()


# ---------------------------------------------------------------------------
# 1. Confluence mass balance: Q_confluence ~= sum(Q_upstream_branches)
# ---------------------------------------------------------------------------

def build_confluence_index(nodes_df: pd.DataFrame, edges_df: pd.DataFrame) -> List[Tuple[int, List[int]]]:
    """
    Precompute, once per graph (not per training step), which node
    indices feed into each real confluence. Returns
    [(confluence_idx, [upstream_idx, ...]), ...] using positional
    indices into nodes_df (0..n-1), matching how a model's output
    tensor would be laid out.

    Splitting this out from the loss function itself means the loss can
    just do array indexing every step -- the graph structure doesn't
    change between training steps, so there's no reason to recompute
    which nodes are involved in each confluence on every call.
    """
    code_to_idx = {code: i for i, code in enumerate(nodes_df["station_code"])}
    confluence_codes = set(nodes_df[nodes_df["is_confluence"]]["station_code"])

    pairs = []
    for conf_code in confluence_codes:
        upstream = edges_df[edges_df["target"] == conf_code]["source"].tolist()
        upstream_idx = [code_to_idx[u] for u in upstream if u in code_to_idx]
        if len(upstream_idx) >= 2:
            pairs.append((code_to_idx[conf_code], upstream_idx))
    return pairs


def confluence_mass_balance_loss(Q, confluence_index: List[Tuple[int, List[int]]]):
    """
    For each real confluence, predicted discharge there should
    approximately equal the sum of its upstream branches' predicted
    discharge -- new mass genuinely enters at a confluence (an
    independent tributary catchment), so this is a straightforward sum,
    unlike the split/rejoin case below.

    Ignores travel time between the branches and the confluence (an
    instantaneous-mass approximation) -- see routing_consistency_loss
    for the piece that accounts for lag separately.

    Args:
        Q: predicted discharge, shape [n_nodes] (one timestep) or
            [n_nodes, T] (multiple timesteps, this loss applies per
            timestep the same way).
        confluence_index: from build_confluence_index.

    Returns:
        Scalar loss (0.0, on the same array type as Q, if no confluences).
    """
    if not confluence_index:
        return Q.sum() * 0.0  # zero, but keeps dtype/type consistent (torch-safe)

    residuals = []
    for conf_idx, upstream_idx in confluence_index:
        upstream_sum = Q[upstream_idx[0]]
        for idx in upstream_idx[1:]:
            upstream_sum = upstream_sum + Q[idx]
        residuals.append(Q[conf_idx] - upstream_sum)

    if _HAS_TORCH and isinstance(Q, torch.Tensor):
        residual_stack = torch.stack(residuals)
    else:
        residual_stack = np.stack(residuals)
    return _mse(residual_stack)


# ---------------------------------------------------------------------------
# 2. Split/rejoin conservation: Q_split ~= Q_rejoin (no new mass between them)
# ---------------------------------------------------------------------------

def build_braid_index(nodes_df: pd.DataFrame) -> List[Tuple[int, int]]:
    """
    Precompute (split_idx, rejoin_idx) pairs from the saved braid_id
    column (see build_reach_graph.py's pair_splits_and_rejoins). Same
    precompute-once rationale as build_confluence_index.
    """
    code_to_idx = {code: i for i, code in enumerate(nodes_df["station_code"])}
    pairs = []
    for _, row in nodes_df[nodes_df["braid_id"].notna()].iterrows():
        rejoin_code, split_code = row["station_code"], row["braid_id"]
        if rejoin_code in code_to_idx and split_code in code_to_idx:
            pairs.append((code_to_idx[split_code], code_to_idx[rejoin_code]))
    return pairs


def split_rejoin_conservation_loss(Q, braid_index: List[Tuple[int, int]]):
    """
    For each matched split/rejoin pair, predicted discharge should be
    approximately equal at both ends -- the same water dividing into
    parallel channels and recombining adds no new mass, unlike a real
    confluence (see confluence_mass_balance_loss). This is a genuinely
    different physical constraint, not a weaker version of the same one:
    a model that learned "sum inflows" generically would get this wrong,
    since a rejoin's two branches together should equal the SPLIT's
    single value, not add something new on top of it.

    Args:
        Q: predicted discharge, shape [n_nodes] or [n_nodes, T].
        braid_index: from build_braid_index.

    Returns:
        Scalar loss (0.0 if no braids in this graph).
    """
    if not braid_index:
        return Q.sum() * 0.0

    split_idx = [s for s, _ in braid_index]
    rejoin_idx = [r for _, r in braid_index]
    residual = Q[split_idx] - Q[rejoin_idx]
    return _mse(residual)


# ---------------------------------------------------------------------------
# 3. Routing: travel-time lag from real channel distance and slope
# ---------------------------------------------------------------------------

def estimate_travel_time_hours(
    distance_km,
    elevation_drop_m,
    min_velocity_ms: float = 0.1,
    max_velocity_ms: float = 3.0,
    velocity_coefficient: float = 1.0,
) -> float:
    """
    Rough channel-flow velocity from slope, in the spirit of Manning's
    equation's slope dependence (v ~ sqrt(slope)) without the channel
    geometry/roughness terms Manning's actually needs, which we don't
    have real data for -- explicitly an approximation, not a full
    hydraulic solve. Slope = elevation_drop_m / (distance_km * 1000).

    Clamped to [min_velocity_ms, max_velocity_ms] since a near-zero or
    negative slope (a virtually flat reach, or a data artifact) would
    otherwise give a nonsensical near-infinite or negative travel time.

    Returns:
        Travel time in hours for water to traverse this edge.
    """
    distance_m = distance_km * 1000.0
    slope = np.clip(elevation_drop_m / np.maximum(distance_m, 1.0), 1e-6, None)
    velocity = np.clip(velocity_coefficient * np.sqrt(slope) * 10.0, min_velocity_ms, max_velocity_ms)
    return distance_m / velocity / 3600.0


def build_routing_index(
    nodes_df: pd.DataFrame, edges_df: pd.DataFrame, timestep_hours: float = 24.0,
) -> List[Tuple[int, int, int]]:
    """
    Precompute (upstream_idx, downstream_idx, lag_timesteps) for every
    edge, rounding each edge's estimated travel time to the nearest
    whole timestep -- e.g. a 30-hour travel time at a 24-hour (daily)
    timestep rounds to a 1-step lag. An edge whose travel time rounds to
    0 is still included (same-timestep routing, lag=0).
    """
    code_to_idx = {code: i for i, code in enumerate(nodes_df["station_code"])}
    pairs = []
    for _, e in edges_df.iterrows():
        if e["source"] not in code_to_idx or e["target"] not in code_to_idx:
            continue
        drop = e["elevation_drop_m"] if pd.notna(e["elevation_drop_m"]) else 0.1
        hours = estimate_travel_time_hours(e["distance_km"], max(drop, 0.1))
        lag = int(round(hours / timestep_hours))
        pairs.append((code_to_idx[e["source"]], code_to_idx[e["target"]], lag))
    return pairs


def routing_consistency_loss(Q, routing_index: List[Tuple[int, int, int]]):
    """
    Q at a downstream node at time t should approximately equal Q at its
    upstream node at time (t - lag), lag coming from real distance and
    slope (build_routing_index) -- not just "conserve mass at the same
    instant," which routing_consistency_loss's siblings above assume as
    a simplification. This is the piece that makes that simplification
    less necessary over time: a well-trained model satisfying this loss
    is learning the actual travel-time behavior of each reach.

    Args:
        Q: predicted discharge, shape [n_nodes, T] -- REQUIRES a time
            dimension, unlike the other three losses, since travel-time
            lag is meaningless for a single instant.
        routing_index: from build_routing_index.

    Returns:
        Scalar loss (0.0 if no edges have a usable lag within Q's time range).
    """
    T = Q.shape[1]
    residuals = []
    for up_idx, down_idx, lag in routing_index:
        if lag >= T:
            continue  # this edge's travel time exceeds the whole prediction window
        if lag == 0:
            residuals.append(Q[down_idx, :] - Q[up_idx, :])
        else:
            residuals.append(Q[down_idx, lag:] - Q[up_idx, :-lag])

    if not residuals:
        return Q.sum() * 0.0

    if _HAS_TORCH and isinstance(Q, torch.Tensor):
        residual_cat = torch.cat(residuals)
    else:
        residual_cat = np.concatenate(residuals)
    return _mse(residual_cat)


# ---------------------------------------------------------------------------
# 4. Water balance: P - ET - Q - deltaS ~= 0, per node
# ---------------------------------------------------------------------------

def water_balance_loss(
    Q_m3s,
    precip_mm,
    evap_mm,
    catchment_area_km2,
    period_days: float = 365.0,
    delta_storage_m3: Optional[object] = None,
):
    """
    Precipitation minus evapotranspiration minus discharge minus storage
    change should balance to ~0, in volume terms, over the given period.

    UNIT CONVERSION (the easy part to get subtly wrong): 1 mm of depth
    over 1 km^2 is 1000 m^3 (1 km^2 = 1e6 m^2, 1 mm = 1e-3 m,
    1e6 * 1e-3 = 1e3). P and ET (mm, over the period) get converted to
    m^3 via catchment_area_km2 before comparing against Q, which is
    converted from a rate (m^3/s) to a volume by multiplying by the
    period length in seconds.

    delta_storage_m3 defaults to zero (a steady-state approximation) --
    we have no direct storage measurement (soil moisture, groundwater
    volume change) in this project's data, only groundwater LEVEL at
    sparse wells, which isn't the same thing as a basin-wide storage
    volume. Treating deltaS as strictly zero is a real, named
    approximation, not a hidden one -- pass a nonzero delta_storage_m3
    if a proxy for it becomes available later (e.g. derived from
    groundwater level trend where well coverage allows it).

    Args:
        Q_m3s: predicted discharge, shape [n_nodes] (period-average rate).
        precip_mm, evap_mm: node features, already available.
        catchment_area_km2: from catchment.py -- NaN for ungauged/unknown
            catchments, in which case that node is excluded from this
            loss entirely (silently including it with a wrong/zero area
            would corrupt the term, not just add noise).
        period_days: length of the period P/ET/Q are aggregated over.
        delta_storage_m3: optional storage change; zero-array default.

    Returns:
        Scalar loss, computed only over nodes with a real catchment area.
    """
    valid = ~np.isnan(catchment_area_km2) if not _HAS_TORCH or not isinstance(catchment_area_km2, torch.Tensor) \
        else ~torch.isnan(catchment_area_km2)

    period_seconds = period_days * 86400.0
    Q_volume_m3 = Q_m3s * period_seconds
    P_volume_m3 = precip_mm * catchment_area_km2 * 1000.0
    ET_volume_m3 = evap_mm * catchment_area_km2 * 1000.0
    dS = delta_storage_m3 if delta_storage_m3 is not None else (Q_m3s * 0.0)

    residual = P_volume_m3 - ET_volume_m3 - Q_volume_m3 - dS
    residual_valid = residual[valid]
    if (residual_valid.shape[0] if hasattr(residual_valid, "shape") else len(residual_valid)) == 0:
        return Q_m3s.sum() * 0.0
    return _mse(residual_valid)


# ---------------------------------------------------------------------------
# Combined loss
# ---------------------------------------------------------------------------

def physics_informed_loss(
    Q_supervised_pred, Q_supervised_true, gauged_mask,
    Q_full, confluence_index, braid_index,
    weights: Optional[Dict[str, float]] = None,
    Q_timeseries=None, routing_index=None,
    precip_mm=None, evap_mm=None, catchment_area_km2=None,
) -> Dict[str, float]:
    """
    Combines the supervised loss (masked to gauged nodes) with all
    physics terms that have the inputs to compute (routing and water
    balance are optional -- they need a time dimension / climate data
    respectively, which not every training step may have on hand).

    Returns a dict of every individual term plus 'total', rather than
    just the summed scalar -- so it's possible to see which physics
    term is actually driving the loss during training, not just that
    "the loss" went up or down.
    """
    weights = weights or {"confluence": 1.0, "split_rejoin": 1.0, "routing": 1.0, "water_balance": 1.0}

    supervised_residual = (Q_supervised_pred - Q_supervised_true)[gauged_mask]
    losses = {"supervised": _mse(supervised_residual)}

    losses["confluence"] = confluence_mass_balance_loss(Q_full, confluence_index)
    losses["split_rejoin"] = split_rejoin_conservation_loss(Q_full, braid_index)

    if Q_timeseries is not None and routing_index is not None:
        losses["routing"] = routing_consistency_loss(Q_timeseries, routing_index)
    if precip_mm is not None and evap_mm is not None and catchment_area_km2 is not None:
        losses["water_balance"] = water_balance_loss(Q_full, precip_mm, evap_mm, catchment_area_km2)

    total = losses["supervised"]
    for name, w_key in [("confluence", "confluence"), ("split_rejoin", "split_rejoin"),
                          ("routing", "routing"), ("water_balance", "water_balance")]:
        if name in losses:
            total = total + weights.get(w_key, 1.0) * losses[name]
    losses["total"] = total
    return losses