"""
Streamlit river network explorer — click directly on the river line (or
use the slider) to read interpolated elevation and estimated groundwater
level at that point.
Click support uses Streamlit's native chart-selection feature
(st.plotly_chart(..., on_select="rerun"), available since Streamlit
1.35) — no extra third-party click-handling package needed, but it does
require `plotly` in addition to `streamlit`:
pip install streamlit plotly
Usage:
streamlit run streamlit_app.py -- --data-root datasets
"""
import argparse
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
import plotly.graph_objects as go
import streamlit as st
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, chain_total_length_km, 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, chain_total_length_km, 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,
)
BASIN_NAMES = {0: "La Eure", 1: "La Risle"}
BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
N_CLICK_TARGETS = 300 # density of clickable points along the line
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
@st.cache_data
def load_graph(data_root: str):
elev_df = StationElevationsLoader(data_path=Path(data_root) / "station_elevations.csv").load()
return build_basin_graph(elev_df)
@st.cache_data
def load_centerlines(data_root: str, _nodes: pd.DataFrame):
result = {}
centerline_dir = Path(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),
}
return result
@st.cache_data
def schematic_click_targets(_nodes, _edges, basin_id: int, n_points: int = N_CLICK_TARGETS):
"""Densely resample the schematic (straight-chain) fallback for 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: str, basin_id: int):
"""Loads the reach-based graph (real confluences + virtual nodes,
from scripts/build_reach_graphs.py) for one basin, or None if it
hasn't been built yet."""
file_key = BASIN_FILE_NAMES[basin_id]
graph_dir = Path(data_root) / "reach_graph"
nodes_path = graph_dir / f"{file_key}_nodes.csv"
edges_path = graph_dir / f"{file_key}_edges.csv"
if not nodes_path.exists() or not edges_path.exists():
return None
# mtime is part of the cache key (see the wrapper below) specifically
# so that re-running scripts/build_reach_graphs.py and overwriting
# these files is picked up automatically -- st.cache_data otherwise
# keys purely on this function's arguments, not file contents, so a
# stale cached graph could silently keep being shown after a rebuild
# (confirmed: this caused a real mismatch between numbers shown here
# and scripts/diagnose_confluences.py reading the same, but freshly
# re-read, file).
return pd.read_csv(nodes_path), pd.read_csv(edges_path)
def _reach_graph_mtime(data_root: str, basin_id: int) -> float:
file_key = BASIN_FILE_NAMES[basin_id]
graph_dir = Path(data_root) / "reach_graph"
nodes_path = graph_dir / f"{file_key}_nodes.csv"
edges_path = graph_dir / f"{file_key}_edges.csv"
if not nodes_path.exists() or not edges_path.exists():
return 0.0
return max(nodes_path.stat().st_mtime, edges_path.stat().st_mtime)
@st.cache_data
def load_reach_graph_cached(data_root: str, basin_id: int, _mtime: float):
"""Cache-safe wrapper: `_mtime` (leading underscore so Streamlit
doesn't try to hash the float oddly, it's just part of the key) makes
the cache key change whenever the underlying CSVs are rewritten, so a
re-run of scripts/build_reach_graphs.py is picked up on the next
Streamlit interaction without needing a manual restart or cache
clear."""
return load_reach_graph(data_root, basin_id)
def build_figure_reach_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, basin_name: str, color: str) -> go.Figure:
"""
Render the full reach graph -- potentially thousands of edges -- as
ONE line trace using None-separated segments, rather than one Plotly
trace per edge. A trace-per-edge approach is fine at the ~10-edge
scale of the old single-chain graph but doesn't hold up at the
5,000+ edges this graph actually has; this stays fast regardless of
graph size since Plotly natively supports gaps via None values in a
single trace's coordinate arrays.
Confluences (real nodes with more than one inflow) and real gauges
are drawn as distinct marker layers on top of the line for visual
validation -- confluences should visibly sit where tributaries
join, and gauges should sit ON the network, not floating near it.
Virtual (infill) nodes are deliberately NOT drawn as individual
markers: at the density this graph uses them, thousands of extra
markers would obscure the actual validation signal rather than
help it -- they're implicitly represented by the line itself.
"""
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 # an edge whose endpoint isn't in nodes_df -- shouldn't happen, skip rather than crash
lon_edges += [src["longitude"], tgt["longitude"], None]
lat_edges += [src["latitude"], tgt["latitude"], None]
if "is_confluence" in nodes_df.columns:
# Authoritative: computed once in build_reach_graph_tables (has full
# graph topology, including the split-rejoin exclusion -- see
# build_reach_graph.py's find_real_confluences/is_split_rejoin).
# Re-deriving this from the flat edges table can't apply that
# exclusion at all, and duplicating the name-only logic here
# previously drifted out of sync with the graph-level count.
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:
# Oldest saved edges.csv, no toponym or is_confluence column at all --
# fall back to the raw (least reliable) in-degree rule.
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"]]
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}",
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=gauges["station_code"],
hovertemplate="%{customdata}",
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
@st.cache_data
def load_groundwater(data_root: str):
ades_dir = Path(data_root) / "ades"
if not ades_dir.exists():
return None
try:
df = ADESLoader(data_path=ades_dir).load()
if {"lat", "lon", "groundwater_level_m"}.issubset(df.columns):
return df.sort_values("date").drop_duplicates("code_bss", keep="last")
except Exception as e:
st.warning(f"Could not load groundwater data: {e}")
return None
@st.cache_data
def load_hydrometric(data_root: str):
"""Returns (loader, df) so plot methods can be called on the loader
directly (they already know how to filter/style), or None if no
hydrometric data is present under data_root/hydrometric."""
hydro_dir = Path(data_root) / "hydrometric"
if not hydro_dir.exists():
return None
try:
loader = HydrometricLoader(data_path=hydro_dir)
df = loader.load()
return loader, df
except FileNotFoundError as e:
st.warning(f"Could not load hydrometric data: {e}")
return None
def nearest_gauge_real(info, distance_from_mouth_km: float):
"""Nearest gauge (by along-centerline distance) to a resolved position,
for the real-centerline branch. Returns (station_code, distance_km)."""
gauges = info["gauges"]
diffs = (gauges["centerline_km"] - distance_from_mouth_km).abs()
idx = diffs.idxmin()
return gauges.loc[idx, "station_code"], float(diffs.loc[idx])
def nearest_gauge_schematic(upstream_station: str, downstream_station: str, fraction: float, edge_length_km: float):
"""Nearest gauge for the schematic (straight-chain) branch: whichever
endpoint of the current edge the fraction is closer to."""
if fraction <= 0.5:
return upstream_station, fraction * edge_length_km
return downstream_station, (1 - fraction) * edge_length_km
def render_station_plots(loader: HydrometricLoader, df: pd.DataFrame, station_code: str, distance_km: float):
"""Waterlevel, discharge, and rating-curve plots for one gauge, reusing
HydrometricLoader's own matplotlib plot methods rather than
reimplementing plotting logic here."""
st.subheader(f"Time series at nearest gauge: {station_code}")
st.caption(f"{distance_km:.1f} km from the clicked/selected position along the river.")
station_df = df[df["station_code"] == station_code]
has_discharge = station_df["discharge_m3s"].notna().any() if "discharge_m3s" in station_df.columns else False
has_waterlevel = station_df["waterlevel_mm"].notna().any() if "waterlevel_mm" in station_df.columns else False
if not has_discharge and not has_waterlevel:
st.info(f"No discharge or water-level observations for {station_code}.")
return
tab_labels = []
if has_waterlevel:
tab_labels.append("Water level")
if has_discharge:
tab_labels.append("Discharge")
if has_discharge and has_waterlevel:
tab_labels.append("Rating curve")
tabs = st.tabs(tab_labels)
tab_iter = iter(tabs)
if has_waterlevel:
with next(tab_iter):
ax = loader.plot_waterlevel(df=station_df, stations=[station_code])
st.pyplot(ax.figure)
if has_discharge:
with next(tab_iter):
ax = loader.plot_discharge(df=station_df, stations=[station_code])
st.pyplot(ax.figure)
if has_discharge and has_waterlevel:
with next(tab_iter):
try:
ax = loader.plot_rating_curve(station_code, df=station_df)
st.pyplot(ax.figure)
except ValueError as e:
st.info(f"Rating curve unavailable: {e}")
plt.close("all") # avoid accumulating open figures across reruns
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()
# Visible line (not a click target: no markers, so it can't be selected).
fig.add_trace(go.Scatter(
x=cl["longitude"], y=cl["latitude"], mode="lines",
line=dict(color=color, width=3), hoverinfo="skip", showlegend=False,
))
# Dense invisible-ish click targets, carrying (km, elevation) as customdata.
fig.add_trace(go.Scatter(
x=targets["longitude"], y=targets["latitude"], mode="markers",
marker=dict(size=10, color=color, opacity=0.01), # near-invisible but clickable
customdata=targets[["distance_from_mouth_km", "elevation_m"]].values,
hovertemplate="%{customdata[0]:.1f} km from mouth
elev %{customdata[1]:.0f} m",
showlegend=False, name="river",
))
# Real ADES well locations, so coverage gaps are visible at a glance
# instead of only showing up as a blank groundwater estimate.
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]}
%{customdata[1]:.1f} m",
showlegend=True, name=f"ADES wells (n={len(wells_df)})",
))
# Gauge markers, colored by elevation, also clickable (own customdata).
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}
%{customdata[0]:.1f} km
elev %{customdata[1]:.0f} m",
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} — click the line",
xaxis_title="Longitude", yaxis_title="Latitude",
yaxis=dict(scaleanchor="x", scaleratio=1),
height=520, margin=dict(l=10, r=10, t=40, b=10),
clickmode="event+select", 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",
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} — click the line (schematic, no digitized centerline)",
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), clickmode="event+select",
)
return fig
def main():
st.set_page_config(page_title="River Network Explorer", layout="centered")
data_root = parse_data_root()
st.title("River Network Explorer")
view = st.radio("View", options=["Explore", "Network validation"], horizontal=True)
basin_id = st.radio("River", options=list(BASIN_NAMES.keys()),
format_func=lambda b: BASIN_NAMES[b], horizontal=True)
basin_name = BASIN_NAMES[basin_id]
color = _PALETTE[basin_id % len(_PALETTE)]
if view == "Network validation":
mtime = _reach_graph_mtime(str(data_root), basin_id)
reach = load_reach_graph_cached(str(data_root), basin_id, mtime)
if reach is None:
st.info(
f"No reach graph found for {basin_name} under "
f"`{data_root}/reach_graph/`. Run `python -m scripts.build_reach_graphs` "
f"first."
)
st.stop()
reach_nodes, reach_edges = reach
has_is_confluence = "is_confluence" in reach_nodes.columns
has_toponym = "toponym" in reach_edges.columns
if has_is_confluence:
n_confluences = int(reach_nodes["is_confluence"].sum())
elif has_toponym:
name_counts = reach_edges.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique()
n_confluences = int((name_counts > 1).sum())
else:
n_confluences = int((reach_edges["target"].value_counts() >= 2).sum())
n_gauged = int(reach_nodes["is_gauged"].sum())
n_virtual = len(reach_nodes) - n_gauged - n_confluences
col1, col2, col3, col4 = st.columns(4)
col1.metric("Nodes", len(reach_nodes))
col2.metric("Edges", len(reach_edges))
col3.metric("Confluences", n_confluences)
col4.metric("Gauges", n_gauged)
if has_is_confluence:
st.caption(
f"Confluence = a genuinely different named river joining, with an "
f"independent upstream source — excludes both same-river tronçon "
f"segmentation AND a channel that splits and rejoins downstream "
f"(braiding/anabranches carry no new mass, even if named differently). "
f"{n_virtual} virtual/infill nodes are not individually marked below."
)
elif has_toponym:
st.caption(
"⚠️ This graph predates the split-rejoin fix — the count above uses "
"name-only matching, which can still misclassify a braided channel "
"that splits and rejoins as a real confluence. Re-run "
"`python -m scripts.build_reach_graphs` to get the corrected count."
)
else:
st.caption(
"⚠️ This graph was built before the name-based confluence fix — the count "
"above uses the old, least reliable 'any node with 2+ incoming edges' rule, "
"which BD TOPO's fine tronçon segmentation inflates well above the real "
"number of tributary junctions. Re-run `python -m scripts.build_reach_graphs` "
"to get the corrected count."
)
st.plotly_chart(build_figure_reach_graph(reach_nodes, reach_edges, basin_name, color),
width="stretch")
max_snap = None
if "snap_distance_km" in reach_nodes.columns:
max_snap = reach_nodes["snap_distance_km"].max()
st.caption(
"Confirm visually: confluences (◆) should sit where a tributary "
"visibly joins the main line, not floating off it or stacked on "
"top of another confluence. Gauges (●) should sit directly on the "
"network, not offset from it."
+ (f" Max real gauge snap distance in this basin: {max_snap:.3f} km."
if max_snap is not None else "")
)
return
st.caption("Click directly on the line (or use the slider) to read elevation and estimated groundwater level.")
try:
nodes, edges = load_graph(str(data_root))
except FileNotFoundError:
st.error(f"Could not find station_elevations.csv under {data_root}")
st.stop()
centerlines = load_centerlines(str(data_root), nodes)
groundwater_df = load_groundwater(str(data_root))
if groundwater_df is not None:
st.caption(f"Loaded {groundwater_df['code_bss'].nunique()} groundwater wells (ADES).")
else:
st.caption("No groundwater well data found — groundwater estimates will be unavailable.")
has_centerline = basin_id in centerlines
click_key = f"click_{basin_id}"
slider_key = f"slider_{basin_id}"
last_click_key = f"last_click_{basin_id}"
if slider_key not in st.session_state:
st.session_state[slider_key] = 50 # start at 50%
if has_centerline:
info = centerlines[basin_id]
fig = build_figure_real(info, basin_name, color, wells_df=groundwater_df)
event = st.plotly_chart(fig, on_select="rerun", selection_mode=["points"], key=click_key)
points = (event or {}).get("selection", {}).get("points", [])
if points:
click_signature = tuple(points[-1]["customdata"])
# Only act on a NEW click: Streamlit's selection state persists across
# reruns (e.g. when the user then moves the slider), so without this
# guard every later rerun would silently snap the slider back to the
# old click position and the slider would appear "stuck".
if st.session_state.get(last_click_key) != click_signature:
st.session_state[last_click_key] = click_signature
km, _elev = click_signature
st.session_state[slider_key] = int(round(100 * float(km) / info["total_km"]))
percent = st.slider(
f"Position along {basin_name} — mouth → source ({info['total_km']:.0f} km real course)",
min_value=0, max_value=100, format="%d%%", key=slider_key,
)
fraction = percent / 100.0
cp = interpolate_by_fraction(info["centerline"], fraction)
elev = elevation_at_km(info["gauges"], cp.distance_from_mouth_km)
lat, lon = cp.latitude, cp.longitude
distance_label = f"{cp.distance_from_mouth_km:.2f} km"
distance_sub = f"from mouth, of {cp.total_length_km:.0f} km total"
nearest_station, nearest_station_km = nearest_gauge_real(info, cp.distance_from_mouth_km)
# Redraw with the marker at the resolved position (click or slider).
fig2 = build_figure_real(info, basin_name, color, wells_df=groundwater_df, marker_lon=lon, marker_lat=lat)
st.plotly_chart(fig2, width="stretch", key=f"{click_key}_marked")
else:
st.info(f"No digitized centerline found for {basin_name} — showing schematic view.")
targets = schematic_click_targets(nodes, edges, basin_id)
fig = build_figure_schematic(targets, basin_name, color)
event = st.plotly_chart(fig, on_select="rerun", selection_mode=["points"], key=click_key)
points = (event or {}).get("selection", {}).get("points", [])
if points:
click_signature = tuple(points[-1]["customdata"])
if st.session_state.get(last_click_key) != click_signature:
st.session_state[last_click_key] = click_signature
frac, _elev = click_signature
st.session_state[slider_key] = int(round(100 * float(frac)))
percent = st.slider(
f"Position along {basin_name} (straight-line schematic)",
min_value=0, max_value=100, format="%d%%", key=slider_key,
)
fraction = percent / 100.0
point = interpolate_along_chain(nodes, edges, basin_id, fraction)
elev = point.elevation_m
lat, lon = point.latitude, point.longitude
distance_label = f"{point.distance_from_upstream_km:.2f} km"
distance_sub = f"of {point.edge_length_km:.2f} km segment"
nearest_station, nearest_station_km = nearest_gauge_schematic(
point.upstream_station, point.downstream_station, point.fraction, point.edge_length_km
)
x_pos = fraction * (len(targets) - 1)
fig2 = build_figure_schematic(targets, basin_name, color, marker_x=x_pos, marker_y=elev)
st.plotly_chart(fig2, width="stretch", key=f"{click_key}_marked")
gwl, nearest_well_km = None, None
if groundwater_df is not None and {"lat", "lon", "groundwater_level_m"}.issubset(groundwater_df.columns):
pseudo_point = RiverPoint(basin_id=basin_id, upstream_station="", downstream_station="",
fraction=0, latitude=lat, longitude=lon, elevation_m=elev or 0,
distance_from_upstream_km=0, edge_length_km=0)
gwl, nearest_well_km = groundwater_at_point(pseudo_point, groundwater_df)
col1, col2, col3 = st.columns(3)
col1.metric("Elevation", f"{elev:.1f} m" if elev is not None else "—")
col2.metric("Distance", distance_label, distance_sub)
if gwl is not None:
col3.metric("Est. groundwater level", f"{gwl:.1f} m")
elif nearest_well_km is not None and nearest_well_km != float("inf"):
col3.metric("Est. groundwater level", "—", f"nearest well is {nearest_well_km:.0f} km away")
else:
col3.metric("Est. groundwater level", "—", "no well data loaded")
st.caption(f"Coordinates: {lat:.4f}, {lon:.4f}")
st.divider()
hydro = load_hydrometric(str(data_root))
if hydro is None:
st.info("No hydrometric data found — station plots unavailable.")
else:
hydro_loader, hydro_df = hydro
render_station_plots(hydro_loader, hydro_df, nearest_station, nearest_station_km)
if __name__ == "__main__":
main()