River_Network / src /graph /dynamic_features.py
ageraustine's picture
Upload folder using huggingface_hub (part 2)
04d6dda verified
Raw
History Blame Contribute Delete
35.2 kB
"""
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 map_stations_to_precipitation_gauges(nodes_df: pd.DataFrame, precip_stations: pd.DataFrame) -> pd.Series:
"""
Which real Météo-France precipitation station each graph node reads
from -- nearest by real haversine distance, same reasoning and same
underlying helper as map_stations_to_grid_cells (the ERA5 grid-cell
assignment): computed once, since the spatial relationship doesn't
change over time, and reused for every date.
Unlike the ERA5 grid (a uniform ~20-30 cell mesh with predictable
spacing), real precipitation gauges are irregularly placed --
genuinely possible for the nearest one to be tens of km away in a
sparsely-instrumented area. No distance cap is applied here: an
imperfect nearest real gauge is still real, directly-measured data,
which was the whole reason to prefer this over ERA5 reanalysis in
the first place -- capping it back down to "use reanalysis instead
beyond some radius" would undo that.
Args:
nodes_df: real graph nodes with latitude/longitude columns.
precip_stations: real Météo-France stations with NUM_POSTE,
LAT, LON columns (as returned by
fetch_meteofrance_precipitation.py's combined output).
Returns:
Series indexed like nodes_df, values are real NUM_POSTE station
codes.
"""
unique_stations = precip_stations.drop_duplicates(subset="NUM_POSTE")
station_lat = unique_stations["LAT"].values
station_lon = unique_stations["LON"].values
station_ids = unique_stations["NUM_POSTE"].values
assignments = []
for _, row in nodes_df.iterrows():
dist = _haversine_km_vec(row["latitude"], row["longitude"], station_lat, station_lon)
assignments.append(station_ids[dist.argmin()])
return pd.Series(assignments, index=nodes_df.index, name="precip_station_id")
def compute_rolling_precip_sum(precip_tensor: np.ndarray, window_days: int = 30, min_coverage: float = 0.5) -> np.ndarray:
"""
Real rolling sum of precipitation over the trailing window_days,
per node per date -- the "recent rainfall" side of the specific-
discharge relationship below. A window built from mostly-missing
days is treated as unreliable (NaN), not silently understated,
same completeness discipline already used for the per-example
lookback-window precip sum in train_spatiotemporal_gnn.py and for
aggregate_anomalies in the climate work.
"""
n_nodes, n_dates = precip_tensor.shape
rolling_sum = np.full((n_nodes, n_dates), np.nan, dtype=np.float32)
for t in range(window_days, n_dates):
window = precip_tensor[:, t - window_days:t]
real_fraction = (~np.isnan(window)).mean(axis=1)
window_sum = np.nansum(window, axis=1)
rolling_sum[:, t] = np.where(real_fraction >= min_coverage, window_sum, np.nan)
return rolling_sum
def fit_specific_discharge_coefficient(
discharge_tensor: np.ndarray, rolling_precip_sum: np.ndarray, catchment_area_km2: np.ndarray,
dates, train_end: str,
) -> float:
"""
Fits k in Q ~= k * (recent precipitation sum) * (catchment area) --
a standard, well-established hydrological technique for estimating
discharge at ungauged points ("regionalization" via specific
discharge scaling), not a novel or speculative one. Fit ONLY on
real (Q, precip, area) triples at GAUGED stations during the real
TRAINING period specifically -- same "fit on train only" discipline
as standardization, avoiding any leakage from val/test into a
coefficient that then gets applied everywhere, including at
ungauged nodes evaluated during val/test.
Closed-form least squares through the origin (no intercept -- zero
rainfall over the window should imply ~zero attributable runoff,
a physically reasonable constraint, not an arbitrary modeling
choice): k = sum(x*y) / sum(x^2), x = precip_sum * area, y = Q.
Returns:
The fitted real scalar k. Raises if there's no real data to
fit against, rather than returning a meaningless default.
"""
train_col_mask = pd.DatetimeIndex(dates) <= pd.Timestamp(train_end)
Q_train = discharge_tensor[:, train_col_mask]
P_train = rolling_precip_sum[:, train_col_mask]
A = catchment_area_km2[:, None] * np.ones_like(Q_train) # broadcast area across all real dates
x = P_train * A
y = Q_train
valid = ~np.isnan(x) & ~np.isnan(y) & (x != 0)
n_real = int(valid.sum())
if n_real == 0:
raise ValueError("No real (discharge, precipitation, catchment area) triples available to fit "
"the specific-discharge coefficient -- check that all three are real for at "
"least some gauged station during the training period.")
x_valid, y_valid = x[valid], y[valid]
k = float(np.sum(x_valid * y_valid) / np.sum(x_valid ** 2))
print(f"Fitted specific-discharge coefficient k={k:.6g} on {n_real} real (Q, precip, area) "
f"triple(s) from gauged stations, training period only")
return k
def estimate_discharge_from_precip(
rolling_precip_sum: np.ndarray, catchment_area_km2: np.ndarray, k: float,
) -> np.ndarray:
"""
Applies the fitted specific-discharge coefficient to EVERY node
(gauged or not), wherever real precipitation and catchment area
both exist -- this is the actual point: an ungauged node with real
precipitation and real catchment area gets a real, physically-
motivated discharge ESTIMATE, computed the same way for every node
rather than only where discharge happens to already be known.
This is deliberately an INPUT feature, never a target -- it must
never be treated as if it were a real observation. It's fit from
real data and grounded in a real, standard hydrological
relationship, but it is still an estimate, not a measurement, and
should reach the model exactly the way every other real-but-
imperfect input does: as a value the model can learn to weigh,
never as something the supervised loss is held accountable to
match exactly.
"""
n_nodes = catchment_area_km2.shape[0]
area_broadcast = catchment_area_km2[:, None] * np.ones((n_nodes, rolling_precip_sum.shape[1]))
estimated = k * rolling_precip_sum * area_broadcast
# NaN propagates automatically wherever rolling_precip_sum or
# catchment_area_km2 is real NaN -- no explicit masking needed here,
# multiplication by NaN is already NaN.
return estimated.astype(np.float32)
FORECAST_LEAD_TIMES = [1, 2, 3, 5, 7] # real confirmed coverage in previous_runs_forecast.csv -- NOT every horizon this project predicts at
def build_forecast_tensor(
nodes_df: pd.DataFrame, forecast_csv_path: Path, anchor_dates, horizons: List[int],
) -> Tuple[np.ndarray, np.ndarray]:
"""
Real forecasted precipitation, per (anchor_date, node, horizon) --
built from previous_runs_forecast.csv, whose real confirmed schema
is genuinely different from every other timeseries builder in this
file: it's not a simple [date x station] wide table, it's one row
per real OBSERVATION date with several "precipitation_previous_
dayN" columns, each holding what was FORECASTED N days before that
date, for that date.
The real mapping this requires, worked through explicitly: for an
anchor date t and horizon h, the forecast we want is "what was
predicted h days in advance, for date t+h" -- which was ISSUED on
date (t+h) - h = t, exactly our anchor. In this file's real column
convention, that value lives in the ROW for date=t+h, in column
precipitation_previous_day{h}. Getting this backwards (e.g. using
the row for date=t instead) would silently use a forecast for the
wrong target date entirely.
station_code in this real file already matches this project's real
graph node station codes directly (confirmed against real sample
data) -- no nearest-neighbor mapping needed here, unlike the
Meteo-France precipitation gauges, which come from a different
network entirely.
Real, confirmed constraints, not assumptions: only horizons in
FORECAST_LEAD_TIMES ([1,2,3,5,7]) have any real forecast data at
all -- every other horizon this project predicts at (4, 10, 30, 60,
90, 180) has NONE, and gets missing=True unconditionally, not
silently filled with anything. Real coverage also only starts
2024-03-01 -- anchor dates before that (the large majority of this
project's 2013+ training period) get missing=True for every
horizon, real lead time or not.
Returns:
forecast_tensor, missing_tensor: both [n_examples, n_nodes,
n_horizons], real values (or 0.0 placeholder) and an explicit
real/missing flag respectively -- same value+missingness
pattern used for every other real-but-incomplete input in this
project.
"""
df = pd.read_csv(forecast_csv_path)
df["date"] = pd.to_datetime(df["date"])
# Pre-indexed for fast repeated lookup -- {(date, station_code): row}
# rather than re-filtering the real dataframe for every single
# (anchor_date, node, horizon) combination, which would be
# prohibitively slow at this project's real scale (thousands of
# examples x 100 nodes x 10 horizons).
df_indexed = df.set_index(["date", "station_code"])
station_codes = nodes_df["station_code"].tolist()
n_nodes = len(station_codes)
n_examples = len(anchor_dates)
n_horizons = len(horizons)
forecast_tensor = np.zeros((n_examples, n_nodes, n_horizons), dtype=np.float32)
missing_tensor = np.ones((n_examples, n_nodes, n_horizons), dtype=np.float32) # starts fully missing
for h_idx, h in enumerate(horizons):
if h not in FORECAST_LEAD_TIMES:
continue # no real forecast data for this horizon at all -- stays fully missing
col = f"precipitation_previous_day{h}"
for ex_idx, anchor in enumerate(anchor_dates):
target_date = pd.Timestamp(anchor) + pd.Timedelta(days=h)
for node_idx, station_code in enumerate(station_codes):
try:
value = df_indexed.loc[(target_date, station_code), col]
except KeyError:
continue # genuinely no real row for this date/station -- stays missing
if pd.notna(value):
forecast_tensor[ex_idx, node_idx, h_idx] = value
missing_tensor[ex_idx, node_idx, h_idx] = 0.0
return forecast_tensor, missing_tensor
def compute_days_since_last_real(tensor, sentinel_days=365.0):
"""
For each [node, date] position, how many real days have passed
since the most recent real (non-NaN) observation at or before that
date -- 0 if the current day itself is real. Built specifically
because a binary missing-flag alone can't distinguish a single-day
sensor glitch from a station offline for months; both look
identical to the model at any given missing timestep without this.
Computed over the FULL real date range, not per-window -- correctly
looks backward past wherever a later 30-day lookback window happens
to start, rather than resetting to "unknown" the moment a window
boundary is crossed. This is exactly why it has to be built once
here, before windowing, and then sliced the same way value/missing
already are, rather than computed fresh inside each window.
Nodes with NO real observation at all up through a given date get
the real sentinel value (a fixed ceiling, not literal infinity) --
"genuinely never observed" is a real, distinct case from "observed
365+ days ago", but both should read as "don't trust this" to the
model without a numerically unbounded value reaching it.
"""
import numpy as np
n_nodes, n_dates = tensor.shape
is_real = ~np.isnan(tensor)
date_indices = np.arange(n_dates, dtype=np.float64)
idx_where_real = np.where(is_real, date_indices[None, :], -1.0)
last_real_idx = np.maximum.accumulate(idx_where_real, axis=1)
dt = np.where(last_real_idx < 0, sentinel_days, date_indices[None, :] - last_real_idx)
dt = np.minimum(dt, sentinel_days)
return dt.astype(np.float32)
def compress_days_since_last_real(dt):
"""
log(1 + dt) -- compresses the real scale so a 3-vs-10-day
distinction isn't drowned out by a 3-vs-3000-day one the way a raw
linear day count would. Same unstandardized-large-number
instability this project already hit once with raw discharge in
L/s, applied preemptively here rather than found the hard way again.
"""
import numpy as np
return np.log1p(dt).astype(np.float32)
def compute_forward_filled_tensor(tensor):
"""
Real forward-filled value per [node, date] position: the most
recent real (non-NaN) observation at or before that date, NaN if
no real observation has occurred yet anywhere before it. Causally
safe by construction -- forward-fill only ever looks backward in
time, so unlike a fitted statistic (a per-node mean/median, which
needs a "fit on train only" discipline to avoid leaking future
information into the fill), this needs no train/val/test split
guard at all: a forward-filled value at any date, in any split,
only ever depends on dates strictly before it.
Built specifically as a fill-value SOURCE for real gaps during a
real low- (or high-) flow episode, where a station-wide central-
tendency fill (compute_per_node_historical_median, in
physics_losses.py) is itself the wrong reference point --
confirmed directly via a real false-spike investigation: even a
real per-node MEDIAN fill (1142.0, for station H605641401) was
still ~17x that station's real low-flow values (57-70 L/s) during
a real gap that fell IN THE MIDDLE of a real low-flow episode,
because a median describes a station's typical day, not
necessarily the day immediately preceding a specific gap. A real,
direct ablation (zeroing the missingness flag and days-since
channel for this exact example) confirmed the flag mechanism alone
cannot fix this: even with the model given no signal at all that
the value was filled, the median-based fill still produced a
predicted 0.95 quantile ~28x the real observed value, because the
filled VALUE itself (1142.0) was implausible for the real
conditions at that moment, not because the model failed to
discount a flagged fill. Forward-fill instead carries the real,
most-recently-known value forward, directly informative about
real LOCAL, CURRENT conditions in a way a station-wide statistic
cannot be.
Same accumulate-forward pattern already validated in
compute_days_since_last_real -- computed over the FULL real date
range, not per-window, for the same reason: correctly looks
backward past wherever a later lookback window happens to start.
"""
n_nodes, n_dates = tensor.shape
is_real = ~np.isnan(tensor)
date_indices = np.arange(n_dates)
idx_where_real = np.where(is_real, date_indices[None, :], -1)
last_real_idx = np.maximum.accumulate(idx_where_real, axis=1)
filled = np.take_along_axis(tensor, np.clip(last_real_idx, 0, None), axis=1)
filled = np.where(last_real_idx < 0, np.nan, filled)
return filled.astype(np.float32)
def build_precipitation_timeseries(
nodes_df: pd.DataFrame, precip_csv_path: Path, date_range: Tuple[str, str], freq: str = "D",
) -> pd.DataFrame:
"""
Real daily precipitation (mm), pivoted to [date x station_code] wide
form -- same output convention as build_discharge_timeseries and
build_waterlevel_timeseries, so it drops into the same downstream
pipeline (assemble_dynamic_tensor etc.) without special-casing.
Real Météo-France gauge data (fetch_meteofrance_precipitation.py),
not ERA5 reanalysis -- deliberately: precipitation varies sharply
over short distances in ways a reanalysis grid cell can miss, so a
real nearby gauge is more locally authoritative where one exists.
Each of OUR graph nodes reads from its own nearest real gauge (see
map_stations_to_precipitation_gauges), which can mean several of
our nodes share the same real gauge if they're close together --
expected and correct, not a bug, the same way several ERA5-grid
climate stations already share one grid cell elsewhere in this
project.
"""
precip_df = pd.read_csv(precip_csv_path)
precip_df["date"] = pd.to_datetime(precip_df["date"])
# CRITICAL, CONFIRMED FIX -- exclude real "ghost" stations (present
# in the real Météo-France station list, with real LAT/LON, but
# ZERO real precip_mm_daily readings across every real row they
# have) from the candidate pool BEFORE nearest-neighbor matching.
# Confirmed directly against a real daily_precipitation.csv: 56 of
# 110 real candidate stations (51%) have precip_mm_daily entirely
# NaN -- a real fetch/ingestion gap in fetch_meteofrance_
# precipitation.py, not a downstream bug -- yet
# map_stations_to_precipitation_gauges has no liveness check, only
# a distance check, so any real graph node whose real geographically
# -nearest station happened to be one of these 56 real ghosts got
# permanently, silently matched to a station that can never
# contribute a single real reading. Confirmed as the real, direct
# cause of 22 of 27 real gauge stations in one real evaluation run
# having ZERO real precipitation anywhere in the full 2013-2026
# record -- every one of forward-fill, the per-node median fallback,
# AND compress_days_since_last_real's real 365-day sentinel were all
# working exactly as designed; there was simply never a real
# observation for any of them to find. Filtering here, once, before
# the nearest-neighbor search, routes every real node to the
# nearest real LIVE station instead -- a real, if sometimes farther,
# station that can actually contribute real data, which is strictly
# better than a real ghost at zero distance.
stations_with_real_data = precip_df.loc[precip_df["precip_mm_daily"].notna(), "NUM_POSTE"].unique()
n_candidates_before = precip_df["NUM_POSTE"].nunique()
precip_stations_live = precip_df[precip_df["NUM_POSTE"].isin(stations_with_real_data)]
n_candidates_after = precip_stations_live["NUM_POSTE"].nunique()
if n_candidates_after < n_candidates_before:
print(f"build_precipitation_timeseries: excluding {n_candidates_before - n_candidates_after} of "
f"{n_candidates_before} real candidate precipitation station(s) with ZERO real "
f"precip_mm_daily readings from nearest-neighbor matching, leaving "
f"{n_candidates_after} real live station(s).")
assignments = map_stations_to_precipitation_gauges(nodes_df, precip_stations_live[["NUM_POSTE", "LAT", "LON"]])
start, end = date_range
full_index = pd.date_range(start, end, freq=freq)
wide = pd.DataFrame(index=full_index)
wide.index.name = "date"
for node_idx, station_id in assignments.items():
station_code = nodes_df.loc[node_idx, "station_code"]
station_series = precip_df[precip_df["NUM_POSTE"] == station_id].set_index("date")["precip_mm_daily"]
wide[station_code] = station_series.reindex(full_index)
return wide
def build_waterlevel_timeseries(
nodes_df: pd.DataFrame, hydrometric_path: Path, date_range: Tuple[str, str],
freq: str = "D",
) -> pd.DataFrame:
"""
Real daily water level (HIXnJ), pivoted to [date x station_code]
wide form -- mirrors build_discharge_timeseries exactly, using
waterlevel_mm instead of discharge_m3s (both already present in
HydrometricLoader's real output, confirmed directly earlier this
project: columns ['date', 'station_code', 'discharge_m3s',
'waterlevel_mm']).
Confirmed real coverage: 13 of 27 real stations have water-level
data at all -- a genuinely different, larger set than discharge's
6-8 in-window stations, though not identical (some stations have
one variable but not the other). Built specifically to give the
model's water-level output channel real supervision for the first
time -- previously it only had one loose consistency constraint
(manning_consistency_loss) and no direct target at all.
"""
from ..data.loaders.hydrometric import HydrometricLoader
loader = HydrometricLoader(data_path=hydrometric_path)
df = loader.load()
if "waterlevel_mm" not in df.columns:
raise ValueError("Loaded hydrometric data has no waterlevel_mm column")
start, end = date_range
df = df[(df["date"] >= start) & (df["date"] <= end)]
wide = df.pivot_table(index="date", columns="station_code", values="waterlevel_mm", 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_grid_timeseries(
safran_path: Path, bbox: Tuple[float, float, float, float], date_range: Tuple[str, str],
) -> Tuple[Dict[str, pd.DataFrame], pd.DataFrame]:
"""
Real ERA5 climate at native 0.25 deg grid resolution -- not
interpolated per station. Confirmed against real data that
station-level interpolation was redundant: several real stations
landed on bit-identical values for the same variable, since they
share the same underlying grid cell. This is the actual fix, not
a bigger version of the same approach.
Returns:
(climate_dict, grid_coords) --
climate_dict: {variable_name: wide DataFrame [date x grid_cell_id]},
same shape/contract as build_climate_timeseries's return, so
every downstream consumer (climatology, later the anomaly
model) works completely unchanged against either.
grid_coords: DataFrame [grid_cell_id, grid_lat, grid_lon] --
needed by map_stations_to_grid_cells (and later, by any river
node) to know which real coordinate each cell ID refers to.
"""
from ..data.loaders.safran import SAFRANLoader
loader = SAFRANLoader(data_path=safran_path)
df = loader.load_grid(bbox=bbox)
df = loader.convert_units(df)
start, end = date_range
df = df[(df["date"] >= start) & (df["date"] <= end)]
df["grid_cell_id"] = df["grid_lat"].round(3).astype(str) + "_" + df["grid_lon"].round(3).astype(str)
grid_coords = df.drop_duplicates("grid_cell_id")[["grid_cell_id", "grid_lat", "grid_lon"]].reset_index(drop=True)
# convert_units() ADDS converted columns rather than replacing the raw
# ones -- confirmed against real data that both temp_2m_K and temp_C
# (and every other raw/converted pair) were being fit separately,
# doubling work for identical underlying signal (a linear unit shift
# produces the exact same climatology skill profile either way).
# wind_u_ms/wind_v_ms are NOT dropped alongside wind_speed_ms -- unlike
# the other pairs, speed is a genuinely different derived quantity
# (loses direction information u/v carry), not a unit conversion of
# either component.
RAW_COLUMNS_SUPERSEDED_BY_CONVERTED = {
"temp_2m_K", "precip_m", "evap_m", "solar_Jm2", "snow_m", "runoff_m",
}
var_cols = [c for c in df.columns
if c not in ("date", "grid_lat", "grid_lon", "grid_cell_id")
and c not in RAW_COLUMNS_SUPERSEDED_BY_CONVERTED]
climate_dict = {}
for var in var_cols:
wide = df.pivot_table(index="date", columns="grid_cell_id", values=var, aggfunc="mean")
climate_dict[var] = wide
return climate_dict, grid_coords
def map_stations_to_grid_cells(nodes_df: pd.DataFrame, grid_coords: pd.DataFrame) -> pd.Series:
"""
Which grid cell each real node reads its climate forecast from --
computed once (the spatial join is time-invariant, same principle
already used for groundwater's nearby-well lookup), not repeated
per timestep or per query. Nearest-cell by exact haversine -- at
only ~20-30 candidate cells for this study area, no KD-tree
prefilter is needed the way it was for ADES's ~100 wells against
~4,500 nodes; a direct distance computation per node against every
cell is cheap at this scale.
Returns:
Series indexed like nodes_df, values are grid_cell_id strings
matching build_climate_grid_timeseries's wide DataFrame columns.
"""
cell_lat = grid_coords["grid_lat"].values
cell_lon = grid_coords["grid_lon"].values
cell_ids = grid_coords["grid_cell_id"].values
assignments = []
for _, row in nodes_df.iterrows():
dist = _haversine_km_vec(row["latitude"], row["longitude"], cell_lat, cell_lon)
assignments.append(cell_ids[dist.argmin()])
return pd.Series(assignments, index=nodes_df.index, name="grid_cell_id")
def _haversine_km_vec(lat1, lon1, lat2, lon2):
R = 6371.0
lat1r, lon1r = np.radians(lat1), np.radians(lon1)
lat2r, lon2r = np.radians(lat2), np.radians(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))
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.
SUPERSEDED for new work by build_climate_grid_timeseries +
map_stations_to_grid_cells: confirmed against real ERA5 data (the
real climatology test run) that several stations land on
bit-identical values here, since ERA5's 0.25 deg cells are coarser
than the spacing between some real gauges -- this function still
works and is now real-data-tested, but it's doing per-station
interpolation onto what's provably a shared, coarser grid. Kept for
any caller that specifically wants a station-indexed table.
"""
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)]
RAW_COLUMNS_SUPERSEDED_BY_CONVERTED = {
"temp_2m_K", "precip_m", "evap_m", "solar_Jm2", "snow_m", "runoff_m",
}
var_cols = [c for c in df.columns
if c not in ("date", "station_code") and c not in RAW_COLUMNS_SUPERSEDED_BY_CONVERTED]
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)