ageraustine commited on
Commit
7807aa2
·
verified ·
1 Parent(s): 6d5b3a1

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. README.md +1 -1
  2. src/gradio_app.py +477 -0
README.md CHANGED
@@ -5,7 +5,7 @@ colorFrom: indigo
5
  colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.47.0
8
- app_file: src/app.py
9
  pinned: true
10
  ---
11
 
 
5
  colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.47.0
8
+ app_file: src/gradio_app.py
9
  pinned: true
10
  ---
11
 
src/gradio_app.py ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import gradio as gr
6
+ import matplotlib.pyplot as plt
7
+ import pandas as pd
8
+ import plotly.graph_objects as go
9
+
10
+ try:
11
+ from .data.loaders.station_elevations import StationElevationsLoader
12
+ from .data.loaders.ades import ADESLoader
13
+ from .data.loaders.hydrometric import HydrometricLoader
14
+ from .data.river_graph import build_basin_graph, _PALETTE
15
+ from .data.river_line import interpolate_along_chain, groundwater_at_point, RiverPoint
16
+ from .data.river_centerline import (
17
+ load_centerline, snap_gauges_to_centerline, cumulative_distance_km,
18
+ interpolate_by_fraction, elevation_at_km, resample_centerline_for_clicks,
19
+ )
20
+ except ImportError:
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+ from src.data.loaders.station_elevations import StationElevationsLoader
23
+ from src.data.loaders.ades import ADESLoader
24
+ from src.data.loaders.hydrometric import HydrometricLoader
25
+ from src.data.river_graph import build_basin_graph, _PALETTE
26
+ from src.data.river_line import interpolate_along_chain, groundwater_at_point, RiverPoint
27
+ from src.data.river_centerline import (
28
+ load_centerline, snap_gauges_to_centerline, cumulative_distance_km,
29
+ interpolate_by_fraction, elevation_at_km, resample_centerline_for_clicks,
30
+ )
31
+
32
+ BASIN_NAMES = {0: "La Eure", 1: "La Risle"}
33
+ BASIN_FILE_NAMES = {0: "eure", 1: "risle"}
34
+ N_CLICK_TARGETS = 300
35
+
36
+
37
+ def parse_data_root() -> Path:
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument("--data-root", type=Path, default=Path("datasets"))
40
+ args, _ = parser.parse_known_args()
41
+ return args.data_root
42
+
43
+
44
+ DATA_ROOT = parse_data_root()
45
+
46
+
47
+ # --- Data Loaders (Cached via simple dictionaries/memoization) ---
48
+ _cache = {}
49
+
50
+ def get_graph(data_root: Path):
51
+ if "graph" not in _cache:
52
+ elev_df = StationElevationsLoader(data_path=data_root / "station_elevations.csv").load()
53
+ _cache["graph"] = build_basin_graph(elev_df)
54
+ return _cache["graph"]
55
+
56
+
57
+ def get_centerlines(data_root: Path, nodes: pd.DataFrame):
58
+ key = "centerlines"
59
+ if key not in _cache:
60
+ result = {}
61
+ centerline_dir = data_root / "centerlines"
62
+ for basin_id, file_key in BASIN_FILE_NAMES.items():
63
+ csv_path = centerline_dir / f"{file_key}_centerline.csv"
64
+ if not csv_path.exists():
65
+ continue
66
+ cl = load_centerline(csv_path)
67
+ b_nodes = nodes[nodes["basin_id"] == basin_id]
68
+ gauges = snap_gauges_to_centerline(cl, b_nodes)
69
+ result[basin_id] = {
70
+ "centerline": cl,
71
+ "gauges": gauges,
72
+ "total_km": float(cumulative_distance_km(cl)[-1]),
73
+ "click_targets": resample_centerline_for_clicks(cl, gauges, n_points=N_CLICK_TARGETS),
74
+ }
75
+ _cache[key] = result
76
+ return _cache[key]
77
+
78
+
79
+ def get_groundwater(data_root: Path):
80
+ key = "groundwater"
81
+ if key not in _cache:
82
+ ades_dir = data_root / "ades"
83
+ df = None
84
+ if ades_dir.exists():
85
+ try:
86
+ loaded_df = ADESLoader(data_path=ades_dir).load()
87
+ if {"lat", "lon", "groundwater_level_m"}.issubset(loaded_df.columns):
88
+ df = loaded_df.sort_values("date").drop_duplicates("code_bss", keep="last")
89
+ except Exception:
90
+ pass
91
+ _cache[key] = df
92
+ return _cache[key]
93
+
94
+
95
+ def get_hydrometric(data_root: Path):
96
+ key = "hydrometric"
97
+ if key not in _cache:
98
+ hydro_dir = data_root / "hydrometric"
99
+ res = None
100
+ if hydro_dir.exists():
101
+ try:
102
+ loader = HydrometricLoader(data_path=hydro_dir)
103
+ df = loader.load()
104
+ res = (loader, df)
105
+ except Exception:
106
+ pass
107
+ _cache[key] = res
108
+ return _cache[key]
109
+
110
+
111
+ def schematic_click_targets(_nodes, _edges, basin_id: int, n_points: int = N_CLICK_TARGETS):
112
+ rows = []
113
+ for i in range(n_points):
114
+ frac = i / (n_points - 1)
115
+ p = interpolate_along_chain(_nodes, _edges, basin_id, frac)
116
+ rows.append({
117
+ "longitude": p.longitude, "latitude": p.latitude,
118
+ "elevation_m": p.elevation_m, "fraction": frac,
119
+ "upstream_station": p.upstream_station, "downstream_station": p.downstream_station,
120
+ })
121
+ return pd.DataFrame(rows)
122
+
123
+
124
+ def load_reach_graph(data_root: Path, basin_id: int):
125
+ file_key = BASIN_FILE_NAMES[basin_id]
126
+ graph_dir = data_root / "reach_graph"
127
+ nodes_path = graph_dir / f"{file_key}_nodes.csv"
128
+ edges_path = graph_dir / f"{file_key}_edges.csv"
129
+ if not nodes_path.exists() or not edges_path.exists():
130
+ return None
131
+ return pd.read_csv(nodes_path), pd.read_csv(edges_path)
132
+
133
+
134
+ # --- Plot Builders ---
135
+ def build_figure_reach_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, basin_name: str, color: str) -> go.Figure:
136
+ lon_edges, lat_edges = [], []
137
+ node_lookup = nodes_df.set_index("station_code")[["latitude", "longitude"]]
138
+ for _, e in edges_df.iterrows():
139
+ try:
140
+ src, tgt = node_lookup.loc[e["source"]], node_lookup.loc[e["target"]]
141
+ except KeyError:
142
+ continue
143
+ lon_edges += [src["longitude"], tgt["longitude"], None]
144
+ lat_edges += [src["latitude"], tgt["latitude"], None]
145
+
146
+ if "is_confluence" in nodes_df.columns:
147
+ confluences = nodes_df[nodes_df["is_confluence"]]
148
+ elif "toponym" in edges_df.columns:
149
+ name_counts = edges_df.dropna(subset=["toponym"]).groupby("target")["toponym"].nunique()
150
+ confluence_codes = name_counts[name_counts > 1].index
151
+ confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)]
152
+ else:
153
+ confluence_codes = edges_df["target"].value_counts()
154
+ confluence_codes = confluence_codes[confluence_codes >= 2].index
155
+ confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)]
156
+ gauges = nodes_df[nodes_df["is_gauged"]]
157
+
158
+ fig = go.Figure()
159
+ fig.add_trace(go.Scatter(
160
+ x=lon_edges, y=lat_edges, mode="lines",
161
+ line=dict(color=color, width=1), hoverinfo="skip", showlegend=False,
162
+ ))
163
+ fig.add_trace(go.Scatter(
164
+ x=confluences["longitude"], y=confluences["latitude"], mode="markers",
165
+ marker=dict(symbol="diamond", size=7, color="#8A8F87", line=dict(width=0.5, color="#5B6B63")),
166
+ customdata=confluences["station_code"],
167
+ hovertemplate="confluence %{customdata}<extra></extra>",
168
+ name=f"confluences (n={len(confluences)})", showlegend=True,
169
+ ))
170
+ fig.add_trace(go.Scatter(
171
+ x=gauges["longitude"], y=gauges["latitude"], mode="markers+text",
172
+ marker=dict(symbol="circle", size=11, color=gauges["elevation_m"], colorscale="earth",
173
+ line=dict(width=1, color="black"), showscale=True, colorbar=dict(title="Elev (m)", thickness=12)),
174
+ text=gauges["station_code"], textposition="top right", textfont=dict(size=8),
175
+ customdata=gauges["station_code"],
176
+ hovertemplate="%{customdata}<extra></extra>",
177
+ name=f"gauges (n={len(gauges)})", showlegend=True,
178
+ ))
179
+ fig.update_layout(
180
+ title=f"{basin_name} — reach graph ({len(nodes_df)} nodes, {len(edges_df)} edges)",
181
+ xaxis_title="Longitude", yaxis_title="Latitude",
182
+ yaxis=dict(scaleanchor="x", scaleratio=1),
183
+ height=650, margin=dict(l=10, r=10, t=40, b=10),
184
+ legend=dict(orientation="h", y=-0.08),
185
+ )
186
+ return fig
187
+
188
+
189
+ def build_figure_real(info, basin_name: str, color: str, wells_df=None, marker_lon=None, marker_lat=None) -> go.Figure:
190
+ cl, gauges, targets = info["centerline"], info["gauges"], info["click_targets"]
191
+ fig = go.Figure()
192
+ fig.add_trace(go.Scatter(
193
+ x=cl["longitude"], y=cl["latitude"], mode="lines",
194
+ line=dict(color=color, width=3), hoverinfo="skip", showlegend=False,
195
+ ))
196
+ fig.add_trace(go.Scatter(
197
+ x=targets["longitude"], y=targets["latitude"], mode="markers",
198
+ marker=dict(size=10, color=color, opacity=0.01),
199
+ customdata=targets[["distance_from_mouth_km", "elevation_m"]].values,
200
+ hovertemplate="%{customdata[0]:.1f} km from mouth<br>elev %{customdata[1]:.0f} m<extra></extra>",
201
+ showlegend=False, name="river",
202
+ ))
203
+ if wells_df is not None and not wells_df.empty:
204
+ fig.add_trace(go.Scatter(
205
+ x=wells_df["lon"], y=wells_df["lat"], mode="markers",
206
+ marker=dict(size=6, color="#8A8F87", opacity=0.55, line=dict(width=0.5, color="#5B6B63")),
207
+ customdata=wells_df[["code_bss", "groundwater_level_m"]].values,
208
+ hovertemplate="well %{customdata[0]}<br>%{customdata[1]:.1f} m<extra></extra>",
209
+ showlegend=True, name=f"ADES wells (n={len(wells_df)})",
210
+ ))
211
+ fig.add_trace(go.Scatter(
212
+ x=gauges["centerline_lon"], y=gauges["centerline_lat"], mode="markers+text",
213
+ marker=dict(size=12, color=gauges["elevation_m"], colorscale="earth",
214
+ line=dict(width=1, color="black"), showscale=True, colorbar=dict(title="Elev (m)", thickness=12)),
215
+ text=gauges["station_code"], textposition="top right", textfont=dict(size=9),
216
+ customdata=gauges[["centerline_km", "elevation_m"]].values,
217
+ hovertemplate="%{text}<br>%{customdata[0]:.1f} km<br>elev %{customdata[1]:.0f} m<extra></extra>",
218
+ showlegend=False, name="gauges",
219
+ ))
220
+ if marker_lon is not None:
221
+ fig.add_trace(go.Scatter(
222
+ x=[marker_lon], y=[marker_lat], mode="markers",
223
+ marker=dict(size=16, color="#C98A28", line=dict(width=2, color="white")),
224
+ hoverinfo="skip", showlegend=False,
225
+ ))
226
+ fig.update_layout(
227
+ title=f"{basin_name} — click the line",
228
+ xaxis_title="Longitude", yaxis_title="Latitude",
229
+ yaxis=dict(scaleanchor="x", scaleratio=1),
230
+ height=520, margin=dict(l=10, r=10, t=40, b=10),
231
+ legend=dict(orientation="h", y=-0.12),
232
+ )
233
+ return fig
234
+
235
+
236
+ def build_figure_schematic(targets, basin_name: str, color: str, marker_x=None, marker_y=None) -> go.Figure:
237
+ fig = go.Figure()
238
+ fig.add_trace(go.Scatter(
239
+ x=list(range(len(targets))), y=targets["elevation_m"], mode="lines",
240
+ line=dict(color=color, width=3), hoverinfo="skip", showlegend=False,
241
+ ))
242
+ fig.add_trace(go.Scatter(
243
+ x=list(range(len(targets))), y=targets["elevation_m"], mode="markers",
244
+ marker=dict(size=10, color=color, opacity=0.01),
245
+ customdata=targets[["fraction", "elevation_m"]].values,
246
+ hovertemplate="%{customdata[1]:.0f} m elevation<extra></extra>",
247
+ showlegend=False,
248
+ ))
249
+ if marker_x is not None:
250
+ fig.add_trace(go.Scatter(
251
+ x=[marker_x], y=[marker_y], mode="markers",
252
+ marker=dict(size=16, color="#C98A28", line=dict(width=2, color="white")),
253
+ hoverinfo="skip", showlegend=False,
254
+ ))
255
+ fig.update_layout(
256
+ title=f"{basin_name} — click the line (schematic, no digitized centerline)",
257
+ xaxis_title="Position along river (upstream → downstream)", yaxis_title="Elevation (m)",
258
+ xaxis=dict(showticklabels=False), height=340,
259
+ margin=dict(l=10, r=10, t=40, b=10),
260
+ )
261
+ return fig
262
+
263
+
264
+ # --- Main Gradio UI Block ---
265
+ with gr.Blocks(title="River Network Explorer") as demo:
266
+ gr.Markdown("# River Network Explorer")
267
+
268
+ with gr.Row():
269
+ view_radio = gr.Radio(options=["Explore", "Network validation"], value="Explore", label="View", interactive=True)
270
+ basin_radio = gr.Radio(choices=list(BASIN_NAMES.keys()), value=0, label="River", interactive=True,
271
+ type="index")
272
+ # Update labels nicely for radio buttons
273
+ basin_radio.choices = [(name, idx) for idx, name in BASIN_NAMES.items()]
274
+
275
+ # Dynamic containers
276
+ explore_group = gr.Group()
277
+ with explore_group:
278
+ status_md = gr.Markdown("Loading datasets...")
279
+ plot_output = gr.Plot(label="River Map")
280
+ slider = gr.Slider(minimum=0, maximum=100, value=50, step=1, label="Position along river (%)")
281
+
282
+ with gr.Row():
283
+ metric_elev = gr.Number(label="Elevation (m)")
284
+ metric_dist = gr.Textbox(label="Distance")
285
+ metric_gw = gr.Textbox(label="Est. groundwater level")
286
+ coords_md = gr.Markdown("Coordinates: —")
287
+
288
+ gr.Markdown("### Station Time Series Plots")
289
+ with gr.Tabs():
290
+ with gr.TabItem("Water Level"):
291
+ plot_waterlevel = gr.Plot()
292
+ with gr.TabItem("Discharge"):
293
+ plot_discharge = gr.Plot()
294
+ with gr.TabItem("Rating Curve"):
295
+ plot_rating = gr.Plot()
296
+
297
+ validation_group = gr.Group(visible=False)
298
+ with validation_group:
299
+ val_metrics_row = gr.Row()
300
+ with val_metrics_row:
301
+ m_nodes = gr.Number(label="Nodes")
302
+ m_edges = gr.Number(label="Edges")
303
+ m_confl = gr.Number(label="Confluences")
304
+ m_gauges = gr.Number(label="Gauges")
305
+ val_caption = gr.Markdown("")
306
+ val_plot_output = gr.Plot(label="Reach Graph Validation")
307
+ val_snap_caption = gr.Markdown("")
308
+
309
+ # --- Event Handlers & Logic ---
310
+ def update_view(view_val, basin_id):
311
+ basin_name = BASIN_NAMES[basin_id]
312
+ color = _PALETTE[basin_id % len(_PALETTE)]
313
+
314
+ try:
315
+ nodes, edges = get_graph(DATA_ROOT)
316
+ except Exception:
317
+ return {
318
+ explore_group: gr.update(visible=True),
319
+ validation_group: gr.update(visible=False),
320
+ status_md: gr.update(value="❌ Could not find station_elevations.csv under data root.")
321
+ }
322
+
323
+ if view_val == "Network validation":
324
+ reach = load_reach_graph(DATA_ROOT, basin_id)
325
+ if reach is None:
326
+ return {
327
+ explore_group: gr.update(visible=False),
328
+ validation_group: gr.update(visible=True),
329
+ val_caption: gr.update(value=f"No reach graph found for {basin_name}. Run `python -m scripts.build_reach_graphs` first.")
330
+ }
331
+ reach_nodes, reach_edges = reach
332
+ n_confluences = int(reach_nodes["is_confluence"].sum()) if "is_confluence" in reach_nodes.columns else 0
333
+ n_gauged = int(reach_nodes["is_gauged"].sum())
334
+
335
+ fig = build_figure_reach_graph(reach_nodes, reach_edges, basin_name, color)
336
+ return {
337
+ explore_group: gr.update(visible=False),
338
+ validation_group: gr.update(visible=True),
339
+ m_nodes: len(reach_nodes),
340
+ m_edges: len(reach_edges),
341
+ m_confl: n_confluences,
342
+ m_gauges: n_gauged,
343
+ val_plot_output: fig,
344
+ val_caption: gr.update(value="Confirm visually: confluences (◆) should sit where a tributary joins."),
345
+ val_snap_caption: gr.update(value="")
346
+ }
347
+ else:
348
+ centerlines = get_centerlines(DATA_ROOT, nodes)
349
+ gw_df = get_groundwater(DATA_ROOT)
350
+ has_centerline = basin_id in centerlines
351
+
352
+ status_text = f"Loaded {gw_df['code_bss'].nunique()} groundwater wells (ADES)." if gw_df is not None else "No groundwater well data found."
353
+
354
+ if has_centerline:
355
+ info = centerlines[basin_id]
356
+ fig = build_figure_real(info, basin_name, color, wells_df=gw_df)
357
+ else:
358
+ targets = schematic_click_targets(nodes, edges, basin_id)
359
+ fig = build_figure_schematic(targets, basin_name, color)
360
+
361
+ return {
362
+ explore_group: gr.update(visible=True),
363
+ validation_group: gr.update(visible=False),
364
+ status_md: gr.update(value=status_text),
365
+ plot_output: fig,
366
+ slider: 50
367
+ }
368
+
369
+ # Bind view and basin switcher
370
+ view_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=[explore_group, validation_group, status_md, plot_output, slider])
371
+ basin_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=[explore_group, validation_group, status_md, plot_output, slider])
372
+
373
+ # Handle slider or map clicks
374
+ def compute_position(basin_id, percent):
375
+ fraction = percent / 100.0
376
+ basin_name = BASIN_NAMES[basin_id]
377
+ color = _PALETTE[basin_id % len(_PALETTE)]
378
+
379
+ try:
380
+ nodes, edges = get_graph(DATA_ROOT)
381
+ except Exception:
382
+ return None
383
+
384
+ centerlines = get_centerlines(DATA_ROOT, nodes)
385
+ gw_df = get_groundwater(DATA_ROOT)
386
+ has_centerline = basin_id in centerlines
387
+
388
+ fig2 = None
389
+ elev, lat, lon, dist_label, dist_sub = None, 0, 0, "", ""
390
+ nearest_station, nearest_station_km = "", 0
391
+
392
+ if has_centerline:
393
+ info = centerlines[basin_id]
394
+ cp = interpolate_by_fraction(info["centerline"], fraction)
395
+ elev = elevation_at_km(info["gauges"], cp.distance_from_mouth_km)
396
+ lat, lon = cp.latitude, cp.longitude
397
+ dist_label = f"{cp.distance_from_mouth_km:.2f} km"
398
+ dist_sub = f"from mouth, of {cp.total_length_km:.0f} km total"
399
+ nearest_station, nearest_station_km = info["gauges"].iloc[(info["gauges"]["centerline_km"] - cp.distance_from_mouth_km).abs().idxmin()]["station_code"], float((info["gauges"]["centerline_km"] - cp.distance_from_mouth_km).abs().min())
400
+ fig2 = build_figure_real(info, basin_name, color, wells_df=gw_df, marker_lon=lon, marker_lat=lat)
401
+ else:
402
+ targets = schematic_click_targets(nodes, edges, basin_id)
403
+ point = interpolate_along_chain(nodes, edges, basin_id, fraction)
404
+ elev = point.elevation_m
405
+ lat, lon = point.latitude, point.longitude
406
+ dist_label = f"{point.distance_from_upstream_km:.2f} km"
407
+ dist_sub = f"of {point.edge_length_km:.2f} km segment"
408
+ nearest_station, nearest_station_km = point.upstream_station, fraction * point.edge_length_km
409
+ x_pos = fraction * (len(targets) - 1)
410
+ fig2 = build_figure_schematic(targets, basin_name, color, marker_x=x_pos, marker_y=elev)
411
+
412
+ gwl, nearest_well_km = None, None
413
+ if gw_df is not None:
414
+ pseudo_point = RiverPoint(basin_id=basin_id, upstream_station="", downstream_station="",
415
+ fraction=0, latitude=lat, longitude=lon, elevation_m=elev or 0,
416
+ distance_from_upstream_km=0, edge_length_km=0)
417
+ gwl, nearest_well_km = groundwater_at_point(pseudo_point, gw_df)
418
+
419
+ 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")
420
+
421
+ # Hydrometric plots
422
+ hydro = get_hydrometric(DATA_ROOT)
423
+ fig_wl, fig_disc, fig_rc = None, None, None
424
+ if hydro is not None:
425
+ loader, hydro_df = hydro
426
+ station_df = hydro_df[hydro_df["station_code"] == nearest_station]
427
+ if not station_df.empty:
428
+ try:
429
+ fig_wl = loader.plot_waterlevel(df=station_df, stations=[nearest_station]).figure
430
+ except Exception:
431
+ pass
432
+ try:
433
+ fig_disc = loader.plot_discharge(df=station_df, stations=[nearest_station]).figure
434
+ except Exception:
435
+ pass
436
+ try:
437
+ fig_rc = loader.plot_rating_curve(nearest_station, df=station_df).figure
438
+ except Exception:
439
+ pass
440
+
441
+ return (
442
+ fig2, elev, dist_label, gw_text, f"Coordinates: {lat:.4f}, {lon:.4f}",
443
+ fig_wl, fig_disc, fig_rc
444
+ )
445
+
446
+ slider.change(compute_position, inputs=[basin_radio, slider], outputs=[plot_output, metric_elev, metric_dist, metric_gw, coords_md, plot_waterlevel, plot_discharge, plot_rating])
447
+
448
+ def on_plot_select(basin_id, select_data: gr.SelectData):
449
+ if not select_data or select_data.index is None:
450
+ return gr.skip()
451
+ # Customdata carries distance or fraction
452
+ try:
453
+ point_idx = select_data.index
454
+ nodes, edges = get_graph(DATA_ROOT)
455
+ centerlines = get_centerlines(DATA_ROOT, nodes)
456
+ if basin_id in centerlines:
457
+ targets = centerlines[basin_id]["click_targets"]
458
+ if point_idx < len(targets):
459
+ km = targets.iloc[point_idx]["distance_from_mouth_km"]
460
+ pct = int(round(100 * float(km) / centerlines[basin_id]["total_km"]))
461
+ return pct
462
+ else:
463
+ targets = schematic_click_targets(nodes, edges, basin_id)
464
+ if point_idx < len(targets):
465
+ frac = targets.iloc[point_idx]["fraction"]
466
+ return int(round(100 * float(frac)))
467
+ except Exception:
468
+ pass
469
+ return gr.skip()
470
+
471
+ plot_output.select(on_plot_select, inputs=[basin_radio], outputs=[slider])
472
+
473
+ # Initial load trigger
474
+ demo.load(update_view, inputs=[view_radio, basin_radio], outputs=[explore_group, validation_group, status_md, plot_output, slider])
475
+
476
+ if __name__ == "__main__":
477
+ demo.launch()