River_Network / src /gradio_app.py
ageraustine's picture
Upload folder using huggingface_hub (part 2)
6754131 verified
Raw
History Blame Contribute Delete
35.8 kB
"""
Gradio river network explorer — slide to read interpolated elevation
and estimated groundwater level along the river.
Usage:
python app.py --data-root datasets
"""
import argparse
import sys
from pathlib import Path
import gradio as gr
import matplotlib.pyplot as plt
import pandas as pd
import plotly.graph_objects as go
try:
from .data.loaders.station_elevations import StationElevationsLoader
from .data.loaders.ades import ADESLoader
from .data.loaders.hydrometric import HydrometricLoader
from .data.river_graph import build_basin_graph, _PALETTE
from .data.river_line import interpolate_along_chain, groundwater_at_point, RiverPoint
from .data.river_centerline import (
load_centerline, snap_gauges_to_centerline, cumulative_distance_km,
interpolate_by_fraction, elevation_at_km, resample_centerline_for_clicks,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.data.loaders.station_elevations import StationElevationsLoader
from src.data.loaders.ades import ADESLoader
from src.data.loaders.hydrometric import HydrometricLoader
from src.data.river_graph import build_basin_graph, _PALETTE
from src.data.river_line import interpolate_along_chain, groundwater_at_point, RiverPoint
from src.data.river_centerline import (
load_centerline, snap_gauges_to_centerline, cumulative_distance_km,
interpolate_by_fraction, elevation_at_km, resample_centerline_for_clicks,
)
import spaces
# This dummy function satisfies the Hugging Face ZeroGPU startup validator
@spaces.GPU(duration=1)
def dummy_gpu_initializer():
return "GPU Initialized"
BASIN_NAMES = {0: "La Eure", 1: "La Risle"}
BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
N_CLICK_TARGETS = 300
def find_data_root() -> Path:
"""
Automatically locate the dataset directory.
Priority:
1. Explicit --data-root argument
2. DATA_ROOT environment variable
3. Common locations relative to app.py
4. Recursive search for a directory containing
station_elevations.csv
"""
parser = argparse.ArgumentParser()
parser.add_argument("--data-root", type=Path, default=None)
args, _ = parser.parse_known_args()
# 1. Explicit CLI argument
if args.data_root is not None:
root = args.data_root.expanduser().resolve()
if root.exists():
return root
# 2. Environment variable
import os
env_root = os.environ.get("DATA_ROOT")
if env_root:
root = Path(env_root).expanduser().resolve()
if root.exists():
return root
# app.py is in src/
app_dir = Path(__file__).resolve().parent
project_root = app_dir.parent
# 3. Common locations
candidates = [
Path.cwd() / "datasets",
Path.cwd() / "data",
project_root / "datasets",
project_root / "data",
app_dir / "datasets",
app_dir / "data",
]
for root in candidates:
if (root / "station_elevations.csv").exists():
return root.resolve()
# 4. Search recursively from likely roots
search_roots = [
Path.cwd(),
project_root,
app_dir,
]
seen = set()
for search_root in search_roots:
if not search_root.exists():
continue
try:
for station_file in search_root.rglob("station_elevations.csv"):
root = station_file.parent.resolve()
if root in seen:
continue
seen.add(root)
# Make sure this actually looks like our dataset
if (
(root / "station_elevations.csv").exists()
and (
(root / "centerlines").exists()
or (root / "reach_graph").exists()
or (root / "hydrometric").exists()
or (root / "ADES").exists()
)
):
return root
except (PermissionError, OSError):
continue
raise FileNotFoundError(
"Could not automatically locate the dataset directory. "
"Expected a directory containing 'station_elevations.csv'. "
"Use --data-root PATH or set DATA_ROOT."
)
DATA_ROOT = find_data_root()
print(f"[River Network Explorer] DATA_ROOT = {DATA_ROOT}")
# def parse_data_root() -> Path:
# parser = argparse.ArgumentParser()
# parser.add_argument("--data-root", type=Path, default=Path("datasets"))
# args, _ = parser.parse_known_args()
# return args.data_root
# DATA_ROOT = parse_data_root()
# --- Data Loaders (Cached via simple dictionaries/memoization) ---
_cache = {}
def get_graph(data_root: Path):
if "graph" not in _cache:
elev_df = StationElevationsLoader(data_path=data_root / "station_elevations.csv").load()
_cache["graph"] = build_basin_graph(elev_df)
return _cache["graph"]
def get_centerlines(data_root: Path, nodes: pd.DataFrame):
key = "centerlines"
if key not in _cache:
result = {}
centerline_dir = data_root / "centerlines"
for basin_id, file_key in BASIN_FILE_NAMES.items():
csv_path = centerline_dir / f"{file_key}_centerline.csv"
if not csv_path.exists():
continue
cl = load_centerline(csv_path)
b_nodes = nodes[nodes["basin_id"] == basin_id]
gauges = snap_gauges_to_centerline(cl, b_nodes)
result[basin_id] = {
"centerline": cl,
"gauges": gauges,
"total_km": float(cumulative_distance_km(cl)[-1]),
"click_targets": resample_centerline_for_clicks(cl, gauges, n_points=N_CLICK_TARGETS),
}
_cache[key] = result
return _cache[key]
def get_groundwater(data_root: Path):
key = "groundwater"
if key not in _cache:
ades_dir = data_root / "ADES"
df = None
if ades_dir.exists():
try:
loaded_df = ADESLoader(data_path=ades_dir).load()
if {"lat", "lon", "groundwater_level_m"}.issubset(loaded_df.columns):
df = loaded_df.sort_values("date").drop_duplicates("code_bss", keep="last")
except Exception:
pass
_cache[key] = df
return _cache[key]
def get_hydrometric(data_root: Path):
key = "hydrometric"
if key not in _cache:
hydro_dir = data_root / "hydrometric"
res = None
if hydro_dir.exists():
try:
loader = HydrometricLoader(data_path=hydro_dir)
df = loader.load()
res = (loader, df)
except Exception:
pass
_cache[key] = res
return _cache[key]
def schematic_click_targets(_nodes, _edges, basin_id: int, n_points: int = N_CLICK_TARGETS):
rows = []
for i in range(n_points):
frac = i / (n_points - 1)
p = interpolate_along_chain(_nodes, _edges, basin_id, frac)
rows.append({
"longitude": p.longitude, "latitude": p.latitude,
"elevation_m": p.elevation_m, "fraction": frac,
"upstream_station": p.upstream_station, "downstream_station": p.downstream_station,
})
return pd.DataFrame(rows)
def load_reach_graph(data_root: Path, basin_id: int):
"""
Loads the enriched reach graph node table when available (real
static features: IDPR, catchment area, landcover, geology, cavité
proximity — see node_features.py) and falls back to the bare
structural table (station_code/coords/is_gauged/etc. only) if the
enrichment step hasn't been run yet. Previously always loaded the
bare table even when the enriched one existed right alongside it --
meaning no real feature values ever reached this app regardless of
whether they'd actually been computed.
"""
file_key = BASIN_FILE_NAMES[basin_id]
graph_dir = data_root / "reach_graph"
enriched_path = graph_dir / f"{file_key}_nodes_enriched.csv"
bare_path = graph_dir / f"{file_key}_nodes.csv"
edges_path = graph_dir / f"{file_key}_edges.csv"
nodes_path = enriched_path if enriched_path.exists() else bare_path
if not nodes_path.exists() or not edges_path.exists():
return None
return pd.read_csv(nodes_path), pd.read_csv(edges_path)
# --- Plot Builders ---
def build_figure_reach_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, basin_name: str, color: str) -> go.Figure:
lon_edges, lat_edges = [], []
node_lookup = nodes_df.set_index("station_code")[["latitude", "longitude"]]
for _, e in edges_df.iterrows():
try:
src, tgt = node_lookup.loc[e["source"]], node_lookup.loc[e["target"]]
except KeyError:
continue
lon_edges += [src["longitude"], tgt["longitude"], None]
lat_edges += [src["latitude"], tgt["latitude"], None]
if "is_confluence" in nodes_df.columns:
confluences = nodes_df[nodes_df["is_confluence"]]
elif "toponym" in edges_df.columns:
name_counts = edges_df.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique()
confluence_codes = name_counts[name_counts > 1].index
confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)]
else:
confluence_codes = edges_df["target"].value_counts()
confluence_codes = confluence_codes[confluence_codes >= 2].index
confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)]
gauges = nodes_df[nodes_df["is_gauged"]].copy()
# Reconstruct single landcover/geology class labels from one-hot columns
# (add_landcover_features/add_geology_features produce landcover_<Class>/
# geology_<Class> boolean columns, not one string column) -- only if
# those columns actually exist in this deployment's enriched table.
def _onehot_label(df: pd.DataFrame, prefix: str) -> pd.Series:
cols = [c for c in df.columns if c.startswith(prefix)]
if not cols:
return pd.Series(["—"] * len(df), index=df.index)
return df[cols].idxmax(axis=1).str[len(prefix):].where(df[cols].any(axis=1), "—")
gauges["_landcover_label"] = _onehot_label(gauges, "landcover_")
gauges["_geology_label"] = _onehot_label(gauges, "geology_")
# Build hover columns from whichever real static features are actually
# present, rather than a fixed set -- not every deployment will have
# every enrichment source run.
hover_fields = [("Elevation", gauges["elevation_m"].round(1).astype(str) + " m")]
if "idpr_value" in gauges.columns:
hover_fields.append(("IDPR", gauges["idpr_value"].astype(str)))
if "catchment_area_km2" in gauges.columns:
hover_fields.append(("Catchment area", gauges["catchment_area_km2"].round(1).astype(str) + " km²"))
if any(c.startswith("landcover_") for c in gauges.columns):
hover_fields.append(("Landcover", gauges["_landcover_label"]))
if any(c.startswith("geology_") for c in gauges.columns):
hover_fields.append(("Geology", gauges["_geology_label"]))
if "ndvi_p50" in gauges.columns:
hover_fields.append(("NDVI (median)", gauges["ndvi_p50"].round(1).astype(str)))
if "distance_to_nearest_cavity_km" in gauges.columns:
hover_fields.append(("Nearest cavity", gauges["distance_to_nearest_cavity_km"].round(1).astype(str) + " km"))
customdata = pd.concat([gauges["station_code"]] + [f[1] for f in hover_fields], axis=1).values
hover_lines = ["<b>%{customdata[0]}</b>"] + [
f"{label}: %{{customdata[{i+1}]}}" for i, (label, _) in enumerate(hover_fields)
]
hovertemplate = "<br>".join(hover_lines) + "<extra></extra>"
fig = go.Figure()
fig.add_trace(go.Scatter(
x=lon_edges, y=lat_edges, mode="lines",
line=dict(color=color, width=1), hoverinfo="skip", showlegend=False,
))
fig.add_trace(go.Scatter(
x=confluences["longitude"], y=confluences["latitude"], mode="markers",
marker=dict(symbol="diamond", size=7, color="#8A8F87", line=dict(width=0.5, color="#5B6B63")),
customdata=confluences["station_code"],
hovertemplate="confluence %{customdata}<extra></extra>",
name=f"confluences (n={len(confluences)})", showlegend=True,
))
fig.add_trace(go.Scatter(
x=gauges["longitude"], y=gauges["latitude"], mode="markers+text",
marker=dict(symbol="circle", size=11, color=gauges["elevation_m"], colorscale="earth",
line=dict(width=1, color="black"), showscale=True, colorbar=dict(title="Elev (m)", thickness=12)),
text=gauges["station_code"], textposition="top right", textfont=dict(size=8),
customdata=customdata,
hovertemplate=hovertemplate,
name=f"gauges (n={len(gauges)})", showlegend=True,
))
fig.update_layout(
title=f"{basin_name} — reach graph ({len(nodes_df)} nodes, {len(edges_df)} edges)",
xaxis_title="Longitude", yaxis_title="Latitude",
yaxis=dict(scaleanchor="x", scaleratio=1),
height=650, margin=dict(l=10, r=10, t=40, b=10),
legend=dict(orientation="h", y=-0.08),
)
return fig
def build_figure_real(info, basin_name: str, color: str, wells_df=None, marker_lon=None, marker_lat=None) -> go.Figure:
cl, gauges, targets = info["centerline"], info["gauges"], info["click_targets"]
fig = go.Figure()
fig.add_trace(go.Scatter(
x=cl["longitude"], y=cl["latitude"], mode="lines",
line=dict(color=color, width=3), hoverinfo="skip", showlegend=False,
))
fig.add_trace(go.Scatter(
x=targets["longitude"], y=targets["latitude"], mode="markers",
marker=dict(size=10, color=color, opacity=0.01),
customdata=targets[["distance_from_mouth_km", "elevation_m"]].values,
hovertemplate="%{customdata[0]:.1f} km from mouth<br>elev %{customdata[1]:.0f} m<extra></extra>",
showlegend=False, name="river",
))
if wells_df is not None and not wells_df.empty:
fig.add_trace(go.Scatter(
x=wells_df["lon"], y=wells_df["lat"], mode="markers",
marker=dict(size=6, color="#8A8F87", opacity=0.55, line=dict(width=0.5, color="#5B6B63")),
customdata=wells_df[["code_bss", "groundwater_level_m"]].values,
hovertemplate="well %{customdata[0]}<br>%{customdata[1]:.1f} m<extra></extra>",
showlegend=True, name=f"ADES wells (n={len(wells_df)})",
))
fig.add_trace(go.Scatter(
x=gauges["centerline_lon"], y=gauges["centerline_lat"], mode="markers+text",
marker=dict(size=12, color=gauges["elevation_m"], colorscale="earth",
line=dict(width=1, color="black"), showscale=True, colorbar=dict(title="Elev (m)", thickness=12)),
text=gauges["station_code"], textposition="top right", textfont=dict(size=9),
customdata=gauges[["centerline_km", "elevation_m"]].values,
hovertemplate="%{text}<br>%{customdata[0]:.1f} km<br>elev %{customdata[1]:.0f} m<extra></extra>",
showlegend=False, name="gauges",
))
if marker_lon is not None:
fig.add_trace(go.Scatter(
x=[marker_lon], y=[marker_lat], mode="markers",
marker=dict(size=16, color="#C98A28", line=dict(width=2, color="white")),
hoverinfo="skip", showlegend=False,
))
fig.update_layout(
title=f"{basin_name} — use slider to navigate",
xaxis_title="Longitude", yaxis_title="Latitude",
yaxis=dict(scaleanchor="x", scaleratio=1),
height=520, margin=dict(l=10, r=10, t=40, b=10),
legend=dict(orientation="h", y=-0.12),
)
return fig
def build_figure_schematic(targets, basin_name: str, color: str, marker_x=None, marker_y=None) -> go.Figure:
fig = go.Figure()
fig.add_trace(go.Scatter(
x=list(range(len(targets))), y=targets["elevation_m"], mode="lines",
line=dict(color=color, width=3), hoverinfo="skip", showlegend=False,
))
fig.add_trace(go.Scatter(
x=list(range(len(targets))), y=targets["elevation_m"], mode="markers",
marker=dict(size=10, color=color, opacity=0.01),
customdata=targets[["fraction", "elevation_m"]].values,
hovertemplate="%{customdata[1]:.0f} m elevation<extra></extra>",
showlegend=False,
))
if marker_x is not None:
fig.add_trace(go.Scatter(
x=[marker_x], y=[marker_y], mode="markers",
marker=dict(size=16, color="#C98A28", line=dict(width=2, color="white")),
hoverinfo="skip", showlegend=False,
))
fig.update_layout(
title=f"{basin_name} — use slider to navigate (schematic)",
xaxis_title="Position along river (upstream → downstream)", yaxis_title="Elevation (m)",
xaxis=dict(showticklabels=False), height=340,
margin=dict(l=10, r=10, t=40, b=10),
)
return fig
# --- Main Gradio UI Block ---
with gr.Blocks(title="River Network Explorer") as demo:
gr.Markdown("# River Network Explorer")
with gr.Row():
view_radio = gr.Radio(choices=["Explore", "Network validation"], value="Explore", label="View", interactive=True)
basin_radio = gr.Radio(choices=[(name, idx) for idx, name in BASIN_NAMES.items()], value=0, label="River", interactive=True)
explore_group = gr.Group()
with explore_group:
status_md = gr.Markdown("Loading datasets...")
plot_output = gr.Plot(label="River Map")
slider = gr.Slider(minimum=0, maximum=100, value=43, step=1, label="Position along river (%)")
with gr.Row():
metric_elev = gr.Number(label="Elevation (m)")
metric_dist = gr.Textbox(label="Distance")
metric_gw = gr.Textbox(label="Est. groundwater level")
coords_md = gr.Markdown("Coordinates: —")
gr.Markdown("### Station details")
timeseries_station_dropdown = gr.Dropdown(
choices=[], label="Jump directly to a station (faster than sliding to find it)", interactive=True,
)
station_details = gr.Dataframe(
headers=["Feature", "Value"], label="Static features", interactive=False, wrap=True,
)
with gr.Tabs():
with gr.TabItem("Water Level"):
plot_waterlevel = gr.Plot()
with gr.TabItem("Discharge"):
plot_discharge = gr.Plot()
with gr.TabItem("Rating Curve"):
plot_rating = gr.Plot()
validation_group = gr.Group(visible=False)
with validation_group:
val_metrics_row = gr.Row()
with val_metrics_row:
m_nodes = gr.Number(label="Nodes")
m_edges = gr.Number(label="Edges")
m_confl = gr.Number(label="Confluences")
m_gauges = gr.Number(label="Gauges")
val_caption = gr.Markdown("")
val_plot_output = gr.Plot(label="Reach Graph Validation")
val_snap_caption = gr.Markdown("")
def update_view(view_val, basin_id):
basin_name = BASIN_NAMES[basin_id]
color = _PALETTE[basin_id % len(_PALETTE)]
default_fig = go.Figure()
try:
nodes, edges = get_graph(DATA_ROOT)
except Exception:
return {
explore_group: gr.update(visible=True),
validation_group: gr.update(visible=False),
status_md: "❌ Could not find station_elevations.csv under data root.",
plot_output: default_fig,
slider: 43,
metric_elev: 0.0,
metric_dist: "",
metric_gw: "No data",
coords_md: "Coordinates: —",
plot_waterlevel: default_fig,
plot_discharge: default_fig,
plot_rating: default_fig,
station_details: pd.DataFrame(columns=["Feature", "Value"]),
timeseries_station_dropdown: gr.update(choices=[], value=None),
}
if view_val == "Network validation":
reach = load_reach_graph(DATA_ROOT, basin_id)
if reach is None:
return {
explore_group: gr.update(visible=False),
validation_group: gr.update(visible=True),
val_caption: f"No reach graph found for {basin_name}. Run `python -m scripts.build_reach_graphs` first.",
val_plot_output: default_fig
}
reach_nodes, reach_edges = reach
n_confluences = int(reach_nodes["is_confluence"].sum()) if "is_confluence" in reach_nodes.columns else 0
n_gauged = int(reach_nodes["is_gauged"].sum())
fig = build_figure_reach_graph(reach_nodes, reach_edges, basin_name, color)
return {
explore_group: gr.update(visible=False),
validation_group: gr.update(visible=True),
m_nodes: len(reach_nodes),
m_edges: len(reach_edges),
m_confl: n_confluences,
m_gauges: n_gauged,
val_plot_output: fig,
val_caption: "Confirm visually: confluences (◆) should sit where a tributary joins. "
"Hover a gauge to see its static features, or switch to Explore for the full detail table.",
val_snap_caption: "",
station_details: pd.DataFrame(columns=["Feature", "Value"]),
timeseries_station_dropdown: gr.update(choices=[], value=None),
}
else:
centerlines = get_centerlines(DATA_ROOT, nodes)
gw_df = get_groundwater(DATA_ROOT)
has_centerline = basin_id in centerlines
status_text = f"Loaded {gw_df['code_bss'].nunique()} groundwater wells (ADES)." if gw_df is not None else "No groundwater well data found."
if has_centerline:
info = centerlines[basin_id]
fig = build_figure_real(info, basin_name, color, wells_df=gw_df)
else:
targets = schematic_click_targets(nodes, edges, basin_id)
fig = build_figure_schematic(targets, basin_name, color)
return {
explore_group: gr.update(visible=True),
validation_group: gr.update(visible=False),
status_md: status_text,
plot_output: fig,
slider: 43,
station_details: pd.DataFrame(columns=["Feature", "Value"]),
timeseries_station_dropdown: gr.update(
choices=sorted(nodes[nodes["basin_id"] == basin_id]["station_code"].tolist()),
value=None,
),
}
def find_nearest_gauge_with_data(station_code, basin_id, hydro_df):
"""
For a station with zero real discharge/water-level rows, find
the nearest OTHER real gauge that does have data -- for display-
only interpolation. Returns (nearest_code, distance_km) or
(None, None) if nothing in this basin has any real data at all.
Distance uses plain haversine on real station coordinates, same
approach used throughout this project's spatial joins -- no new
method invented for this one case.
"""
import math
stations_with_data = set(hydro_df["station_code"].unique())
try:
nodes, _ = get_graph(DATA_ROOT)
except Exception:
return None, None
basin_nodes = nodes[nodes["basin_id"] == basin_id]
target_row = basin_nodes[basin_nodes["station_code"] == station_code]
candidates = basin_nodes[
basin_nodes["station_code"].isin(stations_with_data) & (basin_nodes["station_code"] != station_code)
]
if target_row.empty or candidates.empty:
return None, None
tlat, tlon = target_row.iloc[0]["latitude"], target_row.iloc[0]["longitude"]
def haversine_km(lat1, lon1, lat2, lon2):
R = 6371.0
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
dlat, dlon = lat2 - lat1, lon2 - lon1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
return R * 2 * math.asin(math.sqrt(a))
dists = candidates.apply(lambda r: haversine_km(tlat, tlon, r["latitude"], r["longitude"]), axis=1)
nearest_idx = dists.idxmin()
return candidates.loc[nearest_idx, "station_code"], float(dists.loc[nearest_idx])
def _mark_estimated(fig, real_station_code, distance_km):
"""
Stamps a clear, impossible-to-miss "not a real measurement"
label onto a display-only interpolated figure -- same principle
as this project's existing "Est. groundwater level" labeling
(see the metric_gw textbox above): an estimated value must never
look identical to a real one, anywhere in this app.
"""
try:
fig.suptitle(
f"⚠ ESTIMATED — no real data at this station. Showing nearest real "
f"gauge {real_station_code} ({distance_km:.1f} km away) for display only.",
fontsize=9, color="#B23B00", y=1.02,
)
except Exception:
pass
return fig
def get_station_timeseries_plots(station_code, basin_id=None):
"""
Shared by both the slider (nearest station to the clicked
position) and the direct station dropdown -- same plots, two
ways to choose which station. Avoids duplicating the hydro-
loading/plotting logic in two places that could drift apart.
DISPLAY-ONLY INTERPOLATION: if `station_code` has zero real
discharge/water-level rows, falls back to the nearest real
gauge's actual time series for display, clearly stamped as
estimated (see _mark_estimated). This never touches the real
training data pipeline (node_features.py / dynamic_features.py) --
it exists only inside this app, at display time, on a value that
is discarded immediately after rendering.
"""
default_fig = go.Figure()
hydro = get_hydrometric(DATA_ROOT)
fig_wl, fig_disc, fig_rc = default_fig, default_fig, default_fig
if hydro is not None and station_code:
loader, hydro_df = hydro
station_df = hydro_df[hydro_df["station_code"] == station_code]
plot_code = station_code
estimated_from, estimated_km = None, None
if station_df.empty and basin_id is not None:
estimated_from, estimated_km = find_nearest_gauge_with_data(station_code, basin_id, hydro_df)
if estimated_from is not None:
plot_code = estimated_from
station_df = hydro_df[hydro_df["station_code"] == plot_code]
if not station_df.empty:
try:
fig_wl = loader.plot_waterlevel(df=station_df, stations=[plot_code]).figure
except Exception:
pass
try:
fig_disc = loader.plot_discharge(df=station_df, stations=[plot_code]).figure
except Exception:
pass
try:
fig_rc = loader.plot_rating_curve(plot_code, df=station_df).figure
except Exception:
pass
if estimated_from is not None:
fig_wl = _mark_estimated(fig_wl, estimated_from, estimated_km)
fig_disc = _mark_estimated(fig_disc, estimated_from, estimated_km)
fig_rc = _mark_estimated(fig_rc, estimated_from, estimated_km)
return fig_wl, fig_disc, fig_rc
def compute_position(basin_id, percent):
fraction = percent / 100.0
basin_name = BASIN_NAMES[basin_id]
color = _PALETTE[basin_id % len(_PALETTE)]
default_fig = go.Figure()
try:
nodes, edges = get_graph(DATA_ROOT)
except Exception:
return {
plot_output: default_fig, metric_elev: 0.0, metric_dist: "",
metric_gw: "No data", coords_md: "Coordinates: —",
plot_waterlevel: default_fig, plot_discharge: default_fig, plot_rating: default_fig,
station_details: pd.DataFrame(columns=["Feature", "Value"]),
}
centerlines = get_centerlines(DATA_ROOT, nodes)
gw_df = get_groundwater(DATA_ROOT)
has_centerline = basin_id in centerlines
fig2 = default_fig
elev, lat, lon, dist_label = 0.0, 0.0, 0.0, ""
nearest_station = ""
if has_centerline:
info = centerlines[basin_id]
cp = interpolate_by_fraction(info["centerline"], fraction)
elev = float(elevation_at_km(info["gauges"], cp.distance_from_mouth_km))
lat, lon = cp.latitude, cp.longitude
dist_label = f"{cp.distance_from_mouth_km:.2f} km from mouth"
nearest_station = info["gauges"].iloc[(info["gauges"]["centerline_km"] - cp.distance_from_mouth_km).abs().idxmin()]["station_code"]
fig2 = build_figure_real(info, basin_name, color, wells_df=gw_df, marker_lon=lon, marker_lat=lat)
else:
targets = schematic_click_targets(nodes, edges, basin_id)
point = interpolate_along_chain(nodes, edges, basin_id, fraction)
elev = float(point.elevation_m)
lat, lon = point.latitude, point.longitude
dist_label = f"{point.distance_from_upstream_km:.2f} km from upstream"
nearest_station = point.upstream_station
x_pos = fraction * (len(targets) - 1)
fig2 = build_figure_schematic(targets, basin_name, color, marker_x=x_pos, marker_y=elev)
gwl, nearest_well_km = None, None
if gw_df is not None:
pseudo_point = RiverPoint(basin_id=basin_id, upstream_station="", downstream_station="",
fraction=0, latitude=lat, longitude=lon, elevation_m=elev,
distance_from_upstream_km=0, edge_length_km=0)
gwl, nearest_well_km = groundwater_at_point(pseudo_point, gw_df)
gw_text = f"{gwl:.1f} m" if gwl is not None else (f"No well (nearest {nearest_well_km:.0f}km away)" if nearest_well_km else "No data")
fig_wl, fig_disc, fig_rc = get_station_timeseries_plots(nearest_station, basin_id)
details = show_station_details(basin_id, nearest_station)
return {
plot_output: fig2,
metric_elev: elev,
metric_dist: dist_label,
metric_gw: gw_text,
coords_md: f"Coordinates: {lat:.4f}, {lon:.4f}",
plot_waterlevel: fig_wl,
plot_discharge: fig_disc,
plot_rating: fig_rc,
station_details: details,
}
all_outputs = [
explore_group, validation_group, status_md, plot_output, slider,
metric_elev, metric_dist, metric_gw, coords_md,
plot_waterlevel, plot_discharge, plot_rating,
m_nodes, m_edges, m_confl, m_gauges, val_caption, val_plot_output, val_snap_caption,
station_details, timeseries_station_dropdown,
]
# Human-readable labels for the raw column names, and a deliberate
# allowlist -- e.g. idpr_nearest_point_distance is excluded on purpose:
# it's hardcoded to 0.0 whenever a table is an exact ID match against
# IDPR's own station list (see node_features.py's add_idpr_features),
# which is every real-gauges-only table this app ever builds. Real
# value, not a bug, but a constant 0 conveys nothing to a client.
FEATURE_LABELS = {
"elevation_m": "Elevation (m)",
"idpr_value": "IDPR (infiltration/runoff index)",
"catchment_area_km2": "Catchment area (km²)",
"cumulative_catchment_area_km2": "Catchment area, BD TOPO cumulative (km²)",
"distance_to_nearest_cavity_km": "Distance to nearest known cavity (km)",
"n_cavities_within_20km": "Cavities within 20 km",
"avg_groundwater_level_m": "Groundwater level (m)",
"avg_groundwater_depth_m": "Groundwater depth (m)",
"n_nearby_wells": "Nearby groundwater wells",
"ndvi_p10": "NDVI (10th percentile)",
"ndvi_p50": "NDVI (median)",
"ndvi_p90": "NDVI (90th percentile)",
}
def show_station_details(basin_id, station_code):
if not station_code:
return pd.DataFrame(columns=["Feature", "Value"])
reach = load_reach_graph(DATA_ROOT, basin_id)
if reach is None:
return pd.DataFrame(columns=["Feature", "Value"])
reach_nodes, _ = reach
row = reach_nodes[reach_nodes["station_code"] == station_code]
if row.empty:
return pd.DataFrame(columns=["Feature", "Value"])
row = row.iloc[0]
rows = []
for col, label in FEATURE_LABELS.items():
if col in row.index and pd.notna(row[col]):
value = row[col]
if isinstance(value, float):
# A column with any NaN gets upcast to float by pandas
# even when every real value is whole (IDPR, well
# counts) -- show "885" not "885.00" when the value
# genuinely has no fractional part.
text = str(int(value)) if value == int(value) else f"{value:.2f}"
else:
text = str(value)
rows.append([label, text])
landcover_cols = [c for c in reach_nodes.columns if c.startswith("landcover_")]
if landcover_cols:
active = [c[len("landcover_"):] for c in landcover_cols if row.get(c) == True]
rows.append(["Landcover", active[0] if active else "—"])
geology_cols = [c for c in reach_nodes.columns if c.startswith("geology_")]
if geology_cols:
active = [c[len("geology_"):] for c in geology_cols if row.get(c) == True]
rows.append(["Geology", active[0] if active else "—"])
if not rows:
rows = [["No enriched static features found", "run scripts/enrich_reach_graph.py"]]
return pd.DataFrame(rows, columns=["Feature", "Value"])
view_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
basin_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
def jump_to_station(basin_id, station_code):
"""
Single callback for the Explore-page dropdown: both the dynamic
time-series plots and the static feature table come from one
station selection now, not two separate dropdowns split across
two pages.
"""
fig_wl, fig_disc, fig_rc = get_station_timeseries_plots(station_code, basin_id)
details = show_station_details(basin_id, station_code)
return fig_wl, fig_disc, fig_rc, details
timeseries_station_dropdown.change(
jump_to_station, inputs=[basin_radio, timeseries_station_dropdown],
outputs=[plot_waterlevel, plot_discharge, plot_rating, station_details],
)
slider_outputs = [plot_output, metric_elev, metric_dist, metric_gw, coords_md, plot_waterlevel, plot_discharge, plot_rating, station_details]
slider.change(compute_position, inputs=[basin_radio, slider], outputs=slider_outputs)
demo.load(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
if __name__ == "__main__":
demo.launch()