Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub (part 2)
Browse files- src/__pycache__/gradio_app.cpython-311.pyc +0 -0
- src/gradio_app.py +265 -31
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 |
-
|
|
|
|
| 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=
|
| 283 |
-
hovertemplate=
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 533 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|