River_Network / src /graph /physics_losses.py
ageraustine's picture
Upload folder using huggingface_hub (part 2)
4bb7968 verified
Raw
History Blame Contribute Delete
16 kB
"""
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