""" 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()