""" 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_/ # geology_ 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 = ["%{customdata[0]}"] + [ f"{label}: %{{customdata[{i+1}]}}" for i, (label, _) in enumerate(hover_fields) ] hovertemplate = "
".join(hover_lines) + "" 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=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
elev %{customdata[1]:.0f} m", 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]}
%{customdata[1]:.1f} m", 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}
%{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} — 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", 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()