ageraustine commited on
Commit
6754131
·
verified ·
1 Parent(s): 19f7528

Upload folder using huggingface_hub (part 2)

Browse files
src/__pycache__/gradio_app.cpython-311.pyc CHANGED
Binary files a/src/__pycache__/gradio_app.cpython-311.pyc and b/src/__pycache__/gradio_app.cpython-311.pyc differ
 
src/gradio_app.py CHANGED
@@ -229,10 +229,23 @@ def schematic_click_targets(_nodes, _edges, basin_id: int, n_points: int = N_CLI
229
 
230
 
231
  def load_reach_graph(data_root: Path, basin_id: int):
 
 
 
 
 
 
 
 
 
 
232
  file_key = BASIN_FILE_NAMES[basin_id]
233
  graph_dir = data_root / "reach_graph"
234
- nodes_path = graph_dir / f"{file_key}_nodes.csv"
 
235
  edges_path = graph_dir / f"{file_key}_edges.csv"
 
 
236
  if not nodes_path.exists() or not edges_path.exists():
237
  return None
238
  return pd.read_csv(nodes_path), pd.read_csv(edges_path)
@@ -260,7 +273,43 @@ def build_figure_reach_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, bas
260
  confluence_codes = edges_df["target"].value_counts()
261
  confluence_codes = confluence_codes[confluence_codes >= 2].index
262
  confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)]
263
- gauges = nodes_df[nodes_df["is_gauged"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  fig = go.Figure()
266
  fig.add_trace(go.Scatter(
@@ -279,8 +328,8 @@ def build_figure_reach_graph(nodes_df: pd.DataFrame, edges_df: pd.DataFrame, bas
279
  marker=dict(symbol="circle", size=11, color=gauges["elevation_m"], colorscale="earth",
280
  line=dict(width=1, color="black"), showscale=True, colorbar=dict(title="Elev (m)", thickness=12)),
281
  text=gauges["station_code"], textposition="top right", textfont=dict(size=8),
282
- customdata=gauges["station_code"],
283
- hovertemplate="%{customdata}<extra></extra>",
284
  name=f"gauges (n={len(gauges)})", showlegend=True,
285
  ))
286
  fig.update_layout(
@@ -388,7 +437,13 @@ with gr.Blocks(title="River Network Explorer") as demo:
388
  metric_gw = gr.Textbox(label="Est. groundwater level")
389
  coords_md = gr.Markdown("Coordinates: —")
390
 
391
- gr.Markdown("### Station Time Series Plots")
 
 
 
 
 
 
392
  with gr.Tabs():
393
  with gr.TabItem("Water Level"):
394
  plot_waterlevel = gr.Plot()
@@ -429,7 +484,9 @@ with gr.Blocks(title="River Network Explorer") as demo:
429
  coords_md: "Coordinates: —",
430
  plot_waterlevel: default_fig,
431
  plot_discharge: default_fig,
432
- plot_rating: default_fig
 
 
433
  }
434
 
435
  if view_val == "Network validation":
@@ -454,8 +511,11 @@ with gr.Blocks(title="River Network Explorer") as demo:
454
  m_confl: n_confluences,
455
  m_gauges: n_gauged,
456
  val_plot_output: fig,
457
- val_caption: "Confirm visually: confluences (◆) should sit where a tributary joins.",
458
- val_snap_caption: ""
 
 
 
459
  }
460
  else:
461
  centerlines = get_centerlines(DATA_ROOT, nodes)
@@ -476,9 +536,119 @@ with gr.Blocks(title="River Network Explorer") as demo:
476
  validation_group: gr.update(visible=False),
477
  status_md: status_text,
478
  plot_output: fig,
479
- slider: 43
 
 
 
 
 
480
  }
481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  def compute_position(basin_id, percent):
483
  fraction = percent / 100.0
484
  basin_name = BASIN_NAMES[basin_id]
@@ -491,7 +661,8 @@ with gr.Blocks(title="River Network Explorer") as demo:
491
  return {
492
  plot_output: default_fig, metric_elev: 0.0, metric_dist: "",
493
  metric_gw: "No data", coords_md: "Coordinates: —",
494
- plot_waterlevel: default_fig, plot_discharge: default_fig, plot_rating: default_fig
 
495
  }
496
 
497
  centerlines = get_centerlines(DATA_ROOT, nodes)
@@ -529,24 +700,8 @@ with gr.Blocks(title="River Network Explorer") as demo:
529
 
530
  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")
531
 
532
- hydro = get_hydrometric(DATA_ROOT)
533
- fig_wl, fig_disc, fig_rc = default_fig, default_fig, default_fig
534
- if hydro is not None:
535
- loader, hydro_df = hydro
536
- station_df = hydro_df[hydro_df["station_code"] == nearest_station]
537
- if not station_df.empty:
538
- try:
539
- fig_wl = loader.plot_waterlevel(df=station_df, stations=[nearest_station]).figure
540
- except Exception:
541
- pass
542
- try:
543
- fig_disc = loader.plot_discharge(df=station_df, stations=[nearest_station]).figure
544
- except Exception:
545
- pass
546
- try:
547
- fig_rc = loader.plot_rating_curve(nearest_station, df=station_df).figure
548
- except Exception:
549
- pass
550
 
551
  return {
552
  plot_output: fig2,
@@ -556,20 +711,99 @@ with gr.Blocks(title="River Network Explorer") as demo:
556
  coords_md: f"Coordinates: {lat:.4f}, {lon:.4f}",
557
  plot_waterlevel: fig_wl,
558
  plot_discharge: fig_disc,
559
- plot_rating: fig_rc
 
560
  }
561
 
562
  all_outputs = [
563
  explore_group, validation_group, status_md, plot_output, slider,
564
  metric_elev, metric_dist, metric_gw, coords_md,
565
  plot_waterlevel, plot_discharge, plot_rating,
566
- m_nodes, m_edges, m_confl, m_gauges, val_caption, val_plot_output, val_snap_caption
 
567
  ]
568
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
569
  view_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
570
  basin_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
571
 
572
- slider_outputs = [plot_output, metric_elev, metric_dist, metric_gw, coords_md, plot_waterlevel, plot_discharge, plot_rating]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  slider.change(compute_position, inputs=[basin_radio, slider], outputs=slider_outputs)
574
 
575
  demo.load(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
 
229
 
230
 
231
  def load_reach_graph(data_root: Path, basin_id: int):
232
+ """
233
+ Loads the enriched reach graph node table when available (real
234
+ static features: IDPR, catchment area, landcover, geology, cavité
235
+ proximity — see node_features.py) and falls back to the bare
236
+ structural table (station_code/coords/is_gauged/etc. only) if the
237
+ enrichment step hasn't been run yet. Previously always loaded the
238
+ bare table even when the enriched one existed right alongside it --
239
+ meaning no real feature values ever reached this app regardless of
240
+ whether they'd actually been computed.
241
+ """
242
  file_key = BASIN_FILE_NAMES[basin_id]
243
  graph_dir = data_root / "reach_graph"
244
+ enriched_path = graph_dir / f"{file_key}_nodes_enriched.csv"
245
+ bare_path = graph_dir / f"{file_key}_nodes.csv"
246
  edges_path = graph_dir / f"{file_key}_edges.csv"
247
+
248
+ nodes_path = enriched_path if enriched_path.exists() else bare_path
249
  if not nodes_path.exists() or not edges_path.exists():
250
  return None
251
  return pd.read_csv(nodes_path), pd.read_csv(edges_path)
 
273
  confluence_codes = edges_df["target"].value_counts()
274
  confluence_codes = confluence_codes[confluence_codes >= 2].index
275
  confluences = nodes_df[nodes_df["station_code"].isin(confluence_codes)]
276
+ gauges = nodes_df[nodes_df["is_gauged"]].copy()
277
+
278
+ # Reconstruct single landcover/geology class labels from one-hot columns
279
+ # (add_landcover_features/add_geology_features produce landcover_<Class>/
280
+ # geology_<Class> boolean columns, not one string column) -- only if
281
+ # those columns actually exist in this deployment's enriched table.
282
+ def _onehot_label(df: pd.DataFrame, prefix: str) -> pd.Series:
283
+ cols = [c for c in df.columns if c.startswith(prefix)]
284
+ if not cols:
285
+ return pd.Series(["—"] * len(df), index=df.index)
286
+ return df[cols].idxmax(axis=1).str[len(prefix):].where(df[cols].any(axis=1), "—")
287
+
288
+ gauges["_landcover_label"] = _onehot_label(gauges, "landcover_")
289
+ gauges["_geology_label"] = _onehot_label(gauges, "geology_")
290
+
291
+ # Build hover columns from whichever real static features are actually
292
+ # present, rather than a fixed set -- not every deployment will have
293
+ # every enrichment source run.
294
+ hover_fields = [("Elevation", gauges["elevation_m"].round(1).astype(str) + " m")]
295
+ if "idpr_value" in gauges.columns:
296
+ hover_fields.append(("IDPR", gauges["idpr_value"].astype(str)))
297
+ if "catchment_area_km2" in gauges.columns:
298
+ hover_fields.append(("Catchment area", gauges["catchment_area_km2"].round(1).astype(str) + " km²"))
299
+ if any(c.startswith("landcover_") for c in gauges.columns):
300
+ hover_fields.append(("Landcover", gauges["_landcover_label"]))
301
+ if any(c.startswith("geology_") for c in gauges.columns):
302
+ hover_fields.append(("Geology", gauges["_geology_label"]))
303
+ if "ndvi_p50" in gauges.columns:
304
+ hover_fields.append(("NDVI (median)", gauges["ndvi_p50"].round(1).astype(str)))
305
+ if "distance_to_nearest_cavity_km" in gauges.columns:
306
+ hover_fields.append(("Nearest cavity", gauges["distance_to_nearest_cavity_km"].round(1).astype(str) + " km"))
307
+
308
+ customdata = pd.concat([gauges["station_code"]] + [f[1] for f in hover_fields], axis=1).values
309
+ hover_lines = ["<b>%{customdata[0]}</b>"] + [
310
+ f"{label}: %{{customdata[{i+1}]}}" for i, (label, _) in enumerate(hover_fields)
311
+ ]
312
+ hovertemplate = "<br>".join(hover_lines) + "<extra></extra>"
313
 
314
  fig = go.Figure()
315
  fig.add_trace(go.Scatter(
 
328
  marker=dict(symbol="circle", size=11, color=gauges["elevation_m"], colorscale="earth",
329
  line=dict(width=1, color="black"), showscale=True, colorbar=dict(title="Elev (m)", thickness=12)),
330
  text=gauges["station_code"], textposition="top right", textfont=dict(size=8),
331
+ customdata=customdata,
332
+ hovertemplate=hovertemplate,
333
  name=f"gauges (n={len(gauges)})", showlegend=True,
334
  ))
335
  fig.update_layout(
 
437
  metric_gw = gr.Textbox(label="Est. groundwater level")
438
  coords_md = gr.Markdown("Coordinates: —")
439
 
440
+ gr.Markdown("### Station details")
441
+ timeseries_station_dropdown = gr.Dropdown(
442
+ choices=[], label="Jump directly to a station (faster than sliding to find it)", interactive=True,
443
+ )
444
+ station_details = gr.Dataframe(
445
+ headers=["Feature", "Value"], label="Static features", interactive=False, wrap=True,
446
+ )
447
  with gr.Tabs():
448
  with gr.TabItem("Water Level"):
449
  plot_waterlevel = gr.Plot()
 
484
  coords_md: "Coordinates: —",
485
  plot_waterlevel: default_fig,
486
  plot_discharge: default_fig,
487
+ plot_rating: default_fig,
488
+ station_details: pd.DataFrame(columns=["Feature", "Value"]),
489
+ timeseries_station_dropdown: gr.update(choices=[], value=None),
490
  }
491
 
492
  if view_val == "Network validation":
 
511
  m_confl: n_confluences,
512
  m_gauges: n_gauged,
513
  val_plot_output: fig,
514
+ val_caption: "Confirm visually: confluences (◆) should sit where a tributary joins. "
515
+ "Hover a gauge to see its static features, or switch to Explore for the full detail table.",
516
+ val_snap_caption: "",
517
+ station_details: pd.DataFrame(columns=["Feature", "Value"]),
518
+ timeseries_station_dropdown: gr.update(choices=[], value=None),
519
  }
520
  else:
521
  centerlines = get_centerlines(DATA_ROOT, nodes)
 
536
  validation_group: gr.update(visible=False),
537
  status_md: status_text,
538
  plot_output: fig,
539
+ slider: 43,
540
+ station_details: pd.DataFrame(columns=["Feature", "Value"]),
541
+ timeseries_station_dropdown: gr.update(
542
+ choices=sorted(nodes[nodes["basin_id"] == basin_id]["station_code"].tolist()),
543
+ value=None,
544
+ ),
545
  }
546
 
547
+ def find_nearest_gauge_with_data(station_code, basin_id, hydro_df):
548
+ """
549
+ For a station with zero real discharge/water-level rows, find
550
+ the nearest OTHER real gauge that does have data -- for display-
551
+ only interpolation. Returns (nearest_code, distance_km) or
552
+ (None, None) if nothing in this basin has any real data at all.
553
+
554
+ Distance uses plain haversine on real station coordinates, same
555
+ approach used throughout this project's spatial joins -- no new
556
+ method invented for this one case.
557
+ """
558
+ import math
559
+
560
+ stations_with_data = set(hydro_df["station_code"].unique())
561
+ try:
562
+ nodes, _ = get_graph(DATA_ROOT)
563
+ except Exception:
564
+ return None, None
565
+ basin_nodes = nodes[nodes["basin_id"] == basin_id]
566
+ target_row = basin_nodes[basin_nodes["station_code"] == station_code]
567
+ candidates = basin_nodes[
568
+ basin_nodes["station_code"].isin(stations_with_data) & (basin_nodes["station_code"] != station_code)
569
+ ]
570
+ if target_row.empty or candidates.empty:
571
+ return None, None
572
+ tlat, tlon = target_row.iloc[0]["latitude"], target_row.iloc[0]["longitude"]
573
+
574
+ def haversine_km(lat1, lon1, lat2, lon2):
575
+ R = 6371.0
576
+ lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
577
+ dlat, dlon = lat2 - lat1, lon2 - lon1
578
+ a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
579
+ return R * 2 * math.asin(math.sqrt(a))
580
+
581
+ dists = candidates.apply(lambda r: haversine_km(tlat, tlon, r["latitude"], r["longitude"]), axis=1)
582
+ nearest_idx = dists.idxmin()
583
+ return candidates.loc[nearest_idx, "station_code"], float(dists.loc[nearest_idx])
584
+
585
+ def _mark_estimated(fig, real_station_code, distance_km):
586
+ """
587
+ Stamps a clear, impossible-to-miss "not a real measurement"
588
+ label onto a display-only interpolated figure -- same principle
589
+ as this project's existing "Est. groundwater level" labeling
590
+ (see the metric_gw textbox above): an estimated value must never
591
+ look identical to a real one, anywhere in this app.
592
+ """
593
+ try:
594
+ fig.suptitle(
595
+ f"⚠ ESTIMATED — no real data at this station. Showing nearest real "
596
+ f"gauge {real_station_code} ({distance_km:.1f} km away) for display only.",
597
+ fontsize=9, color="#B23B00", y=1.02,
598
+ )
599
+ except Exception:
600
+ pass
601
+ return fig
602
+
603
+ def get_station_timeseries_plots(station_code, basin_id=None):
604
+ """
605
+ Shared by both the slider (nearest station to the clicked
606
+ position) and the direct station dropdown -- same plots, two
607
+ ways to choose which station. Avoids duplicating the hydro-
608
+ loading/plotting logic in two places that could drift apart.
609
+
610
+ DISPLAY-ONLY INTERPOLATION: if `station_code` has zero real
611
+ discharge/water-level rows, falls back to the nearest real
612
+ gauge's actual time series for display, clearly stamped as
613
+ estimated (see _mark_estimated). This never touches the real
614
+ training data pipeline (node_features.py / dynamic_features.py) --
615
+ it exists only inside this app, at display time, on a value that
616
+ is discarded immediately after rendering.
617
+ """
618
+ default_fig = go.Figure()
619
+ hydro = get_hydrometric(DATA_ROOT)
620
+ fig_wl, fig_disc, fig_rc = default_fig, default_fig, default_fig
621
+ if hydro is not None and station_code:
622
+ loader, hydro_df = hydro
623
+ station_df = hydro_df[hydro_df["station_code"] == station_code]
624
+ plot_code = station_code
625
+ estimated_from, estimated_km = None, None
626
+
627
+ if station_df.empty and basin_id is not None:
628
+ estimated_from, estimated_km = find_nearest_gauge_with_data(station_code, basin_id, hydro_df)
629
+ if estimated_from is not None:
630
+ plot_code = estimated_from
631
+ station_df = hydro_df[hydro_df["station_code"] == plot_code]
632
+
633
+ if not station_df.empty:
634
+ try:
635
+ fig_wl = loader.plot_waterlevel(df=station_df, stations=[plot_code]).figure
636
+ except Exception:
637
+ pass
638
+ try:
639
+ fig_disc = loader.plot_discharge(df=station_df, stations=[plot_code]).figure
640
+ except Exception:
641
+ pass
642
+ try:
643
+ fig_rc = loader.plot_rating_curve(plot_code, df=station_df).figure
644
+ except Exception:
645
+ pass
646
+ if estimated_from is not None:
647
+ fig_wl = _mark_estimated(fig_wl, estimated_from, estimated_km)
648
+ fig_disc = _mark_estimated(fig_disc, estimated_from, estimated_km)
649
+ fig_rc = _mark_estimated(fig_rc, estimated_from, estimated_km)
650
+ return fig_wl, fig_disc, fig_rc
651
+
652
  def compute_position(basin_id, percent):
653
  fraction = percent / 100.0
654
  basin_name = BASIN_NAMES[basin_id]
 
661
  return {
662
  plot_output: default_fig, metric_elev: 0.0, metric_dist: "",
663
  metric_gw: "No data", coords_md: "Coordinates: —",
664
+ plot_waterlevel: default_fig, plot_discharge: default_fig, plot_rating: default_fig,
665
+ station_details: pd.DataFrame(columns=["Feature", "Value"]),
666
  }
667
 
668
  centerlines = get_centerlines(DATA_ROOT, nodes)
 
700
 
701
  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")
702
 
703
+ fig_wl, fig_disc, fig_rc = get_station_timeseries_plots(nearest_station, basin_id)
704
+ details = show_station_details(basin_id, nearest_station)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
 
706
  return {
707
  plot_output: fig2,
 
711
  coords_md: f"Coordinates: {lat:.4f}, {lon:.4f}",
712
  plot_waterlevel: fig_wl,
713
  plot_discharge: fig_disc,
714
+ plot_rating: fig_rc,
715
+ station_details: details,
716
  }
717
 
718
  all_outputs = [
719
  explore_group, validation_group, status_md, plot_output, slider,
720
  metric_elev, metric_dist, metric_gw, coords_md,
721
  plot_waterlevel, plot_discharge, plot_rating,
722
+ m_nodes, m_edges, m_confl, m_gauges, val_caption, val_plot_output, val_snap_caption,
723
+ station_details, timeseries_station_dropdown,
724
  ]
725
 
726
+ # Human-readable labels for the raw column names, and a deliberate
727
+ # allowlist -- e.g. idpr_nearest_point_distance is excluded on purpose:
728
+ # it's hardcoded to 0.0 whenever a table is an exact ID match against
729
+ # IDPR's own station list (see node_features.py's add_idpr_features),
730
+ # which is every real-gauges-only table this app ever builds. Real
731
+ # value, not a bug, but a constant 0 conveys nothing to a client.
732
+ FEATURE_LABELS = {
733
+ "elevation_m": "Elevation (m)",
734
+ "idpr_value": "IDPR (infiltration/runoff index)",
735
+ "catchment_area_km2": "Catchment area (km²)",
736
+ "cumulative_catchment_area_km2": "Catchment area, BD TOPO cumulative (km²)",
737
+ "distance_to_nearest_cavity_km": "Distance to nearest known cavity (km)",
738
+ "n_cavities_within_20km": "Cavities within 20 km",
739
+ "avg_groundwater_level_m": "Groundwater level (m)",
740
+ "avg_groundwater_depth_m": "Groundwater depth (m)",
741
+ "n_nearby_wells": "Nearby groundwater wells",
742
+ "ndvi_p10": "NDVI (10th percentile)",
743
+ "ndvi_p50": "NDVI (median)",
744
+ "ndvi_p90": "NDVI (90th percentile)",
745
+ }
746
+
747
+ def show_station_details(basin_id, station_code):
748
+ if not station_code:
749
+ return pd.DataFrame(columns=["Feature", "Value"])
750
+ reach = load_reach_graph(DATA_ROOT, basin_id)
751
+ if reach is None:
752
+ return pd.DataFrame(columns=["Feature", "Value"])
753
+ reach_nodes, _ = reach
754
+ row = reach_nodes[reach_nodes["station_code"] == station_code]
755
+ if row.empty:
756
+ return pd.DataFrame(columns=["Feature", "Value"])
757
+ row = row.iloc[0]
758
+
759
+ rows = []
760
+ for col, label in FEATURE_LABELS.items():
761
+ if col in row.index and pd.notna(row[col]):
762
+ value = row[col]
763
+ if isinstance(value, float):
764
+ # A column with any NaN gets upcast to float by pandas
765
+ # even when every real value is whole (IDPR, well
766
+ # counts) -- show "885" not "885.00" when the value
767
+ # genuinely has no fractional part.
768
+ text = str(int(value)) if value == int(value) else f"{value:.2f}"
769
+ else:
770
+ text = str(value)
771
+ rows.append([label, text])
772
+
773
+ landcover_cols = [c for c in reach_nodes.columns if c.startswith("landcover_")]
774
+ if landcover_cols:
775
+ active = [c[len("landcover_"):] for c in landcover_cols if row.get(c) == True]
776
+ rows.append(["Landcover", active[0] if active else "—"])
777
+
778
+ geology_cols = [c for c in reach_nodes.columns if c.startswith("geology_")]
779
+ if geology_cols:
780
+ active = [c[len("geology_"):] for c in geology_cols if row.get(c) == True]
781
+ rows.append(["Geology", active[0] if active else "—"])
782
+
783
+ if not rows:
784
+ rows = [["No enriched static features found", "run scripts/enrich_reach_graph.py"]]
785
+ return pd.DataFrame(rows, columns=["Feature", "Value"])
786
+
787
  view_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
788
  basin_radio.change(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)
789
 
790
+ def jump_to_station(basin_id, station_code):
791
+ """
792
+ Single callback for the Explore-page dropdown: both the dynamic
793
+ time-series plots and the static feature table come from one
794
+ station selection now, not two separate dropdowns split across
795
+ two pages.
796
+ """
797
+ fig_wl, fig_disc, fig_rc = get_station_timeseries_plots(station_code, basin_id)
798
+ details = show_station_details(basin_id, station_code)
799
+ return fig_wl, fig_disc, fig_rc, details
800
+
801
+ timeseries_station_dropdown.change(
802
+ jump_to_station, inputs=[basin_radio, timeseries_station_dropdown],
803
+ outputs=[plot_waterlevel, plot_discharge, plot_rating, station_details],
804
+ )
805
+
806
+ slider_outputs = [plot_output, metric_elev, metric_dist, metric_gw, coords_md, plot_waterlevel, plot_discharge, plot_rating, station_details]
807
  slider.change(compute_position, inputs=[basin_radio, slider], outputs=slider_outputs)
808
 
809
  demo.load(update_view, inputs=[view_radio, basin_radio], outputs=all_outputs)