Spaces:
Running on Zero
Running on Zero
| """ | |
| 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 | |
| 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() |