glof / chatbot /agentic_rag /tools.py
Freate16's picture
Add RIVERS_PATH and BUILDINGS_PATH to execute_python_code docstring so agent knows about them
fa40066
Raw
History Blame Contribute Delete
31.5 kB
import sys
import torch # CRITICAL: Import torch first to prevent WinError 1114 on Windows
import os
import math
from datetime import date, datetime
from pathlib import Path
import warnings
import contextlib
import io
import re
# Suppress noisy warnings
warnings.filterwarnings("ignore")
# Heavy GIS/ML Imports (Top-level to prevent DLL conflicts on Windows)
try:
import numpy as np
import pandas as pd
import geopandas as gpd
import rasterio
import fiona
import pyproj
import ee
import geemap
import matplotlib.pyplot as plt
import segmentation_models_pytorch as smp
from rasterio.features import rasterize
from scipy.ndimage import label
from shapely.geometry import box, Point
from shapely.ops import transform as shapely_transform
except Exception as e:
print(f"Warning: Failed to load some GIS/ML libraries: {e}")
# Setup paths to import existing modules
DEPLOYMENT_ROOT = Path(__file__).resolve().parent.parent.parent
PROJECT_ROOT = DEPLOYMENT_ROOT
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
RAG_ROOT = PROJECT_ROOT / "chatbot" / "rag"
if str(RAG_ROOT) not in sys.path:
sys.path.insert(0, str(RAG_ROOT))
from chatbot.rag.kg_store import KgStore
from chatbot.rag.hybrid_graphrag import retrieve_vector_context
# The import block handles potential missing tools gracefully
try:
from chatbot.dual_inference import explain_glof_prediction
except ImportError:
explain_glof_prediction = None
KG_STORE_DIR = PROJECT_ROOT / "chatbot" / "rag" / "kg_store"
INDEX_DIR = PROJECT_ROOT / "chatbot" / "rag" / "index"
# Configuration for GIS Data
DATA_DIR = PROJECT_ROOT / "gis_data"
LAKES_PATH = DATA_DIR / "stage2_dataset_15.gpkg"
RIVERS_PATH = DATA_DIR / "river_network.gpkg"
BUILDINGS_PATH = DATA_DIR / "final_merged_clipped_buildings.gpkg"
# --- Detection Tool Constants ---
CKPT_PATH = PROJECT_ROOT / "models" / "lake_segmentation" / "best_model.pth"
DETECTION_OUT_DIR = PROJECT_ROOT / "agent_detection_outputs"
# Normalization stats for Detection model
S2_MEAN = torch.tensor([857.6, 1044.0, 1356.7, 1574.4, 1786.2, 2076.5, 2215.2, 2277.8, 2348.5, 2243.7, 2665.0, 2217.2]).view(12, 1, 1)
S2_STD = torch.tensor([626.3, 709.2, 748.7, 825.5, 773.5, 786.1, 810.8, 834.9, 833.6, 729.6, 875.7, 851.3]).view(12, 1, 1)
S1_MEAN = torch.tensor([-9.25, -18.00]).view(2, 1, 1)
S1_STD = torch.tensor([5.90, 5.92]).view(2, 1, 1)
DEM_MEAN = torch.tensor([4299.8]).view(1, 1, 1)
DEM_STD = torch.tensor([901.0]).view(1, 1, 1)
# We create a persistent global dictionary so variables persist between python executions
GLOBAL_PYTHON_STATE = {
'LAKES_PATH': str(LAKES_PATH),
'RIVERS_PATH': str(RIVERS_PATH),
'BUILDINGS_PATH': str(BUILDINGS_PATH)
}
def execute_python_code(code: str) -> str:
"""
Use this tool to execute arbitrary Python code for spatial reasoning and data analysis.
SCHEMA GUIDE:
- Primary dataset: `LAKES_PATH` (A pre-defined global variable containing the path to the geopackage).
- River dataset: `RIVERS_PATH` (Contains river network with 'river_id' and 'geometry').
- Buildings dataset: `BUILDINGS_PATH` (Contains structures with 'geometry').
- Dataset CRS: ESRI:102025 (Asia North Albers, units=meters).
- Elevation Columns: Use 'Elevation' or 'surface_elevation_m' for lake elevation.
- LAKE TYPE MAPPING: Use 'lake_type' column with these codes:
* M(e): End-moraine Dammed
* M(l): Lateral Moraine Dammed
* M(lg): Lateral Moraine Dammed (with ice)
* M(o): Other Moraine Dammed
* O: Other Glacial Lake
* E(c): Cirque Erosion
* E(o): Other Glacial Erosion
* E(v): Glacier Trough Valley Erosion
- MULTI-LAKE QUERY (CRITICAL FOR CASCADING GLOFS): To compare elevations of multiple lakes, pass their GLAKE_IDs in a list.
Example:
```python
import geopandas as gpd
lakes = gpd.read_file(LAKES_PATH)
target_ids = ['GL088814E27991N', 'GL088743E27992N']
subset = lakes[lakes['GLAKE_ID'].isin(target_ids)]
print(subset[['GLAKE_ID', 'Elevation']].to_string(index=False))
```
- COORDINATE QUERIES (CRITICAL): User provides Lat/Lon (WGS84). You MUST re-project the point to the dataset CRS (ESRI:102025) before calculating distances.
Example:
```python
import geopandas as gpd
from shapely.geometry import Point
lakes = gpd.read_file(LAKES_PATH)
# 1. Create point in WGS84 (EPSG:4326), then re-project to dataset's projected CRS (ESRI:102025)
user_pt = gpd.GeoDataFrame([{'geometry': Point(88.70, 28.98)}], crs="EPSG:4326").to_crs(lakes.crs).geometry.iloc[0]
# 2. Query within 10km (10000 meters)
nearby = lakes[lakes.geometry.distance(user_pt) < 10000]
if not nearby.empty:
avg_elev = nearby['Elevation'].mean()
print(f"Found {len(nearby)} lakes. Average Elevation: {avg_elev:.2f}m")
else:
print("No lakes found within 10km of coordinates.")
```
Input: A valid, executable Python script. Use print() for results.
"""
# Clean up markdown formatting if the LLM adds it
code = code.strip()
if code.startswith("```python"):
code = code[9:]
elif code.startswith("```"):
code = code[3:]
if code.endswith("```"):
code = code[:-3]
# Capture standard output
output_buffer = io.StringIO()
try:
with contextlib.redirect_stdout(output_buffer):
# Execute the code in the persistent global state
exec(code, GLOBAL_PYTHON_STATE)
output = output_buffer.getvalue()
if not output.strip():
return "Code executed successfully, but nothing was printed. Use print() to output results."
return output
except Exception as e:
import traceback
return f"Python Error:\n{traceback.format_exc()}"
def get_dataset_info(dummy_input: str = "") -> str:
"""
Returns the schema and summary statistics of the glacial lake dataset.
Use this BEFORE writing Python code if you are unsure about column names or unique values.
Input: Any string (ignored).
"""
try:
lakes = gpd.read_file(LAKES_PATH)
info = [f"Dataset Schema (Top 20 columns of {len(lakes.columns)}):", ", ".join(lakes.columns[:20])]
info.append("\nSample lake_type values: " + ", ".join(lakes['lake_type'].dropna().unique()[:10]))
info.append(f"\nTotal Records: {len(lakes)}")
return "\n".join(info)
except Exception as e:
return f"Error reading schema: {str(e)}"
def query_lakes_by_subcatchment(subcatchment_id: str) -> str:
"""
Finds all glacial lakes belonging to a specific sub-catchment or basin ID.
Use this when the user provides a numeric basin ID (e.g., '170067682').
Returns a list of Lake IDs and their basic properties (Elevation, CAGR, Area, Type).
Input: The numeric sub-catchment ID.
"""
try:
# Normalize input
sid_str = subcatchment_id.strip().replace("'", "").replace('"', "")
sid = float(sid_str)
lakes = gpd.read_file(LAKES_PATH)
# Handle potential variations in column name
col = 'sub-catchment_id' if 'sub-catchment_id' in lakes.columns else 'sub-catchment'
in_basin = lakes[lakes[col] == sid]
if in_basin.empty:
return f"No lakes found in sub-catchment {subcatchment_id}."
# Sort by CAGR to help the agent find the "highest"
if 'area_cagr_total_pct' in in_basin.columns:
in_basin = in_basin.sort_values(by='area_cagr_total_pct', ascending=False)
res = [f"Found {len(in_basin)} lake(s) in sub-catchment {subcatchment_id}:"]
for _, row in in_basin.head(15).iterrows():
cagr = row.get('area_cagr_total_pct', 0.0)
elev = row.get('Elevation', row.get('surface_elevation_m', 'N/A'))
if isinstance(elev, float): elev = f"{elev:.1f}m"
res.append(f"- ID: {row['GLAKE_ID']} | Elev: {elev} | CAGR: {cagr:.4f}% | Type: {row.get('lake_type', 'N/A')}")
return "\n".join(res)
except Exception as e:
return f"Error querying sub-catchment: {str(e)}"
def query_lake_properties_kg(lake_id: str) -> str:
"""
Use this tool to get exact, structured facts about a specific glacial lake from the Knowledge Graph.
This includes area, volume, elevation, dam type, and growth trends (CAGR).
Input: The exact Lake ID (e.g., 'GL088561E28014N').
Returns: A structured string of lake properties.
"""
kg = KgStore(KG_STORE_DIR)
props = kg.lake_properties_by_id(lake_id)
if not props:
return f"Lake ID {lake_id} not found in the Knowledge Graph."
return "\n".join(props)
def query_downstream_rivers_kg(lake_id: str) -> str:
"""
Use this tool to perform topological reasoning. It finds which river catchment and sub-catchment a lake belongs to,
which rivers are connected to a lake, and traces the downstream path.
Input: The exact Lake ID.
Returns: A list of connected river catchments and downstream exposure elements.
"""
kg = KgStore(KG_STORE_DIR)
res = kg.associated_river_for_lake_text(lake_id)
if not res:
return f"No downstream river connection found for Lake {lake_id}."
return res
def is_tabular_query(query: str) -> bool:
lowered = query.lower()
has_table_signal = any(term in lowered for term in ["table", "distribution", "range", "stat", "count", "number of"])
has_category_signal = any(term in lowered for term in ["elevation", "area", "type", "moraine", "erosion"])
return has_table_signal and has_category_signal
def search_scientific_papers(query: str) -> str:
"""
Use this tool to search ingested PDFs, scientific papers, and literature reports.
Use this when you need background knowledge, causes, mechanisms, or historical facts.
Returns excerpts and structured Markdown tables from scientific literature.
Input: A search query string (e.g., 'Table for elevation distribution in Ganga basin').
"""
# Adjust parameters for tabular/statistical queries to favor BM25 (keywords)
top_k = 5
alpha = 0.65
if is_tabular_query(query):
top_k = 10 # Increase depth to catch tables near headers
alpha = 0.45 # Favor BM25 (keywords) for structured table lookup
print(f"DEBUG: Tabular query detected. Using hybrid alpha={alpha}, top_k={top_k}")
from chatbot.rag.hybrid_graphrag import retrieve_vector_context
context, evidence = retrieve_vector_context(
query,
index_dir=INDEX_DIR,
top_k=top_k,
dense_top_k=30,
bm25_top_k=30,
alpha=alpha
)
if not context:
return "No relevant scientific papers found for this query."
return context
def get_glof_risk_forecast(lake_id: str) -> str:
"""
Use this tool to run live machine learning predictions (XGBoost & GNN) for a glacial lake.
It returns the current GLOF susceptibility probability, hazard index, and recent weather anomalies.
Input: The exact Lake ID.
Returns: A forecast report string.
"""
if not explain_glof_prediction:
return "Forecasting model is offline or unavailable."
try:
explanation = explain_glof_prediction(lake_id)
if not explanation:
return f"No forecast data available for {lake_id}."
return explanation
except Exception as e:
return f"Error running forecast for {lake_id}: {str(e)}"
def get_regional_glof_forecast(input_str: str) -> str:
"""
Runs a batched GLOF risk forecast for all lakes within a specified radius (buffer)
of a central point (lat/lon). This is much faster than querying lakes individually.
Use this when the user asks about risk in a "region", "area", "basin", or "near [coordinates]".
Input: A comma-separated string "lat, lon, buffer_km" (e.g., '27.895, 87.013, 10.0').
"""
try:
parts = [p.strip() for p in input_str.split(',')]
if len(parts) < 2:
return "Error: Regional forecast requires at least 'lat, lon'. Example: '27.895, 87.013, 5.0'"
lat = float(parts[0])
lon = float(parts[1])
buffer_km = float(parts[2]) if len(parts) > 2 else 50.0
from chatbot.dual_inference import explain_regional_glof
return explain_regional_glof(lat, lon, buffer_km)
except Exception as e:
return f"Error parsing regional forecast input: {str(e)}"
def gaussian_window_2d(size, sigma=None, device="cpu"):
import torch
if sigma is None: sigma = size / 4.0
coords = torch.arange(size, device=device, dtype=torch.float32) - (size - 1) / 2.0
g1 = torch.exp(-(coords ** 2) / (2.0 * sigma ** 2))
g2 = g1[:, None] * g1[None, :]
return g2 / g2.max()
def predict_sliding_window(model, tensor, window=224, stride=64, batch_size=4, device="cuda"):
import torch
"""Runs inference over a large image using an overlapping sliding window."""
_, H, W = tensor.shape
pad_h = max(0, window - H)
pad_w = max(0, window - W)
if pad_h or pad_w:
tensor = torch.nn.functional.pad(tensor.unsqueeze(0), (0, pad_w, 0, pad_h), mode='reflect').squeeze(0)
_, H, W = tensor.shape
starts_y = list(range(0, H - window + 1, stride))
starts_x = list(range(0, W - window + 1, stride))
if starts_y[-1] + window < H: starts_y.append(H - window)
if starts_x[-1] + window < W: starts_x.append(W - window)
coords = [(y, x) for y in starts_y for x in starts_x]
weights = gaussian_window_2d(window, sigma=window/6.0, device=device)
pred_logits = torch.zeros((H, W), device=device, dtype=torch.float32)
pred_weights = torch.zeros((H, W), device=device, dtype=torch.float32)
for offset in range(0, len(coords), batch_size):
chunk = coords[offset: offset + batch_size]
batch = torch.stack([tensor[:, y:y+window, x:x+window] for y, x in chunk]).to(device)
with torch.amp.autocast(device_type="cuda", enabled=(device == "cuda" or str(device) == "cuda")):
logits = model(batch)
for i, (y, x) in enumerate(chunk):
pred_logits[y:y+window, x:x+window] += logits[i, 0].float() * weights
pred_weights[y:y+window, x:x+window] += weights
prob = torch.sigmoid(pred_logits / pred_weights.clamp_min(1e-6)).cpu().numpy()
if pad_h or pad_w:
prob = prob[:H-pad_h, :W-pad_w]
return prob
def detect_lakes_and_calculate_area(input_str: str) -> str:
"""
Perform deep-learning based glacial lake detection and area trend analysis using Sentinel-2, Sentinel-1, and DEM data from GEE.
WARNING: This tool is slow. USE ONLY IF requested by the user.
Input format: "lake_id, mode, start, end"
Modes: 'yearly' or 'monthly'.
Example Yearly: 'GL088561E28014N, yearly, 2021, 2025'
Example Monthly: 'GL088561E28014N, monthly, 2024-06, 2025-01'
"""
import numpy as np
import pandas as pd
import torch
import segmentation_models_pytorch as smp
import ee
import geemap
import rasterio
import matplotlib.pyplot as plt
from rasterio.features import rasterize
from scipy.ndimage import label
from shapely.geometry import box
from shapely.ops import transform as shapely_transform
import geopandas as gpd
import pyproj
from datetime import datetime
from dateutil.relativedelta import relativedelta
from pathlib import Path
parts = [p.strip() for p in input_str.split(',')]
if len(parts) != 4:
return "Invalid input format. Use: 'lake_id, mode, start, end'. Example: 'GL088561E28014N, monthly, 2024-06, 2025-01'"
lake_id, mode, start_str, end_str = parts[0], parts[1].lower(), parts[2], parts[3]
try:
LAKES_PATH = Path(__file__).resolve().parent.parent.parent / "gis_data" / "stage2_dataset_15.gpkg"
DETECTION_OUT_DIR = Path(__file__).resolve().parent.parent.parent / "agent_detection_outputs"
# 1. Load Lake Geometry
lakes_db = gpd.read_file(LAKES_PATH)
target = lakes_db[lakes_db['GLAKE_ID'].str.upper() == lake_id.upper()]
if target.empty: return f"Lake {lake_id} not found."
target_wgs84 = target.to_crs("EPSG:4326")
bbox = list(target_wgs84.total_bounds)
bbox = [bbox[0]-0.01, bbox[1]-0.01, bbox[2]+0.01, bbox[3]+0.01]
lake_geom = target_wgs84.geometry.iloc[0]
# 2. Time Periods
time_periods = []
if mode == 'yearly':
s_year, e_year = int(start_str), int(end_str)
for y in range(s_year, e_year + 1):
time_periods.append({"label": str(y), "start": f"{y}-07-01", "end": f"{y}-10-31"})
elif mode == 'monthly':
s_date = datetime.strptime(start_str, "%Y-%m")
e_date = datetime.strptime(end_str, "%Y-%m")
curr_date = s_date
while curr_date <= e_date:
next_month = curr_date + relativedelta(months=1)
end_of_month = next_month - relativedelta(days=1)
time_periods.append({
"label": curr_date.strftime("%Y-%m"),
"start": curr_date.strftime("%Y-%m-%d"),
"end": end_of_month.strftime("%Y-%m-%d")
})
curr_date = next_month
else:
return "Invalid mode. Use 'yearly' or 'monthly'."
# 3. GEE Init & Model Load
import os
import json
ee_creds_json = os.environ.get("EE_SERVICE_ACCOUNT_JSON")
try:
if ee_creds_json:
creds_dict = json.loads(ee_creds_json)
credentials = ee.ServiceAccountCredentials(creds_dict['client_email'], key_data=ee_creds_json)
project_id = creds_dict.get('project_id')
ee.Initialize(credentials, project=project_id)
else:
ee.Initialize()
except Exception as e:
return f"GEE Authentication failed. Please ensure the EE_SERVICE_ACCOUNT_JSON secret is correctly configured in your Space settings. Error: {str(e)}"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = smp.Unet(encoder_name="se_resnet50", encoder_weights=None, in_channels=15, classes=1, activation=None)
ckpt_path = Path(__file__).resolve().parent.parent.parent / "models" / "lake_segmentation" / "best_model.pth"
if not ckpt_path.exists(): return f"Model weights not found at {ckpt_path}"
ckpt = torch.load(ckpt_path, map_location=device)
model.load_state_dict(ckpt["state_dict"])
model.to(device).eval()
results = []
out_dir = DETECTION_OUT_DIR / lake_id
out_dir.mkdir(parents=True, exist_ok=True)
for period in time_periods:
date_str = period['label']
aoi = ee.Geometry.BBox(*bbox)
s2 = ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED").filterBounds(aoi).filterDate(period['start'], period['end']).median()
s1 = ee.ImageCollection("COPERNICUS/S1_GRD").filterBounds(aoi).filterDate(period['start'], period['end']).select(['VV', 'VH']).median()
dem = ee.ImageCollection("COPERNICUS/DEM/GLO30").filterBounds(aoi).select('DEM').mosaic()
composite = ee.Image.cat([s2.select(['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B11', 'B12']), s1, dem]).clip(aoi).toFloat()
tif_path = out_dir / f"{date_str}_comp.tif"
import contextlib, io as io2
with contextlib.redirect_stdout(io2.StringIO()), contextlib.redirect_stderr(io2.StringIO()):
geemap.download_ee_image(composite, str(tif_path), region=aoi, crs="EPSG:32645", scale=10)
with rasterio.open(tif_path) as ds:
arr = ds.read()
transform = ds.transform
pixel_area = abs(transform.a * transform.e) / 1e6
t = torch.from_numpy(np.nan_to_num(arr)).float()
S2_MEAN = torch.tensor([857.6, 1044.0, 1356.7, 1574.4, 1786.2, 2076.5, 2215.2, 2277.8, 2348.5, 2243.7, 2665.0, 2217.2]).view(12, 1, 1)
S2_STD = torch.tensor([626.3, 709.2, 748.7, 825.5, 773.5, 786.1, 810.8, 834.9, 833.6, 729.6, 875.7, 851.3]).view(12, 1, 1)
S1_MEAN = torch.tensor([-9.25, -18.00]).view(2, 1, 1)
S1_STD = torch.tensor([5.90, 5.92]).view(2, 1, 1)
DEM_MEAN = torch.tensor([4299.8]).view(1, 1, 1)
DEM_STD = torch.tensor([901.0]).view(1, 1, 1)
t[0:12] = torch.clamp((t[0:12] - S2_MEAN) / S2_STD, -10, 10)
t[12:14] = torch.clamp((t[12:14] - S1_MEAN) / S1_STD, -10, 10)
t[14:15] = torch.clamp((t[14:15] - DEM_MEAN) / DEM_STD, -10, 10)
with torch.no_grad():
prob_mask = predict_sliding_window(model, t, device=device)
binary_mask = prob_mask >= 0.8
# Visual Overlay
rgb = np.stack([arr[3], arr[2], arr[1]], axis=-1)
rgb_img = np.zeros_like(rgb, dtype=np.uint8)
for i in range(3):
channel = rgb[:, :, i]
valid = channel[np.isfinite(channel) & (channel > 0)]
if valid.size > 0:
lo, hi = np.percentile(valid, [2, 98])
if hi <= lo: hi = lo + 1.0
rgb_img[:, :, i] = (np.clip((channel - lo) / (hi - lo), 0, 1) * 255).astype(np.uint8)
overlay_color = np.zeros((*binary_mask.shape, 3), dtype=np.uint8)
overlay_color[binary_mask] = [0, 220, 255]
overlay = (0.7 * rgb_img.astype(np.float32) + 0.3 * overlay_color.astype(np.float32)).astype(np.uint8)
plt.imsave(str(out_dir / f"{date_str}_prediction.png"), overlay)
labeled, num = label(binary_mask)
counts = np.bincount(labeled.ravel())
project = pyproj.Transformer.from_crs("EPSG:4326", ds.crs, always_xy=True).transform
geom_proj = shapely_transform(project, lake_geom)
zone = rasterize([(geom_proj, 1)], out_shape=binary_mask.shape, transform=transform, fill=0)
intersect_ids = np.unique(labeled[zone == 1])
intersect_ids = intersect_ids[intersect_ids > 0]
area = counts[intersect_ids].sum() * pixel_area
results.append({"date": date_str, "area_km2": float(area)})
summary = [f"--- Satellite Lake Detection Report: {lake_id} ({mode.upper()}) ---"]
area_str = " -> ".join([f"{r['date']}: {r['area_km2']:.3f}km2" for r in results])
summary.append(f"Trend: {area_str}")
summary.append(f"Files saved to: {out_dir.resolve()}")
return "\n".join(summary)
except Exception as e:
import traceback
err_msg = f"Detection Error: {str(e)}\n{traceback.format_exc()}"
print(err_msg, file=sys.stderr)
return err_msg
def simulate_glof_flood_impact(lake_id: str) -> str:
"""
Runs a GIS simulation to estimate GLOF impact.
1. Calculates peak discharge using Huggel's empirical formula.
2. Traces the flood zone 50km downstream along the river network.
3. Identifies specific buildings at risk by fixing CRS projection issues.
Input: The exact Lake ID.
Returns: A text report of the estimated discharge and count of buildings at risk.
"""
import geopandas as gpd
from shapely.geometry import box
print(f"Simulating flood impact for {lake_id}...")
try:
# 1. Load Lake Data
lakes = gpd.read_file(LAKES_PATH)
target_lake = lakes[lakes['GLAKE_ID'].str.upper() == lake_id.upper()]
if target_lake.empty:
return f"Lake {lake_id} not found in the spatial database."
lake_geom = target_lake.geometry.iloc[0]
metric_crs = lakes.crs
# 2. Get Volume and Peak Discharge
# Prioritize existing volume data
if 'volume_m3' in target_lake.columns and not target_lake['volume_m3'].isna().iloc[0]:
est_volume = target_lake['volume_m3'].iloc[0]
source_note = " (from database)"
else:
# Fallback to empirical formula using correct area column name
lake_area = target_lake['area_m2'].iloc[0] if 'area_m2' in target_lake.columns else 100000
est_volume = 0.04 * (lake_area ** 1.2)
source_note = " (estimated from area)"
# Empirical Peak Discharge (Huggel et al., 2002 approximation)
q_max = 0.0048 * (est_volume ** 0.896)
# 3. Find Downstream Path (Improved Topological Tracing)
rivers = gpd.read_file(RIVERS_PATH)
if rivers.crs != metric_crs:
rivers = rivers.to_crs(metric_crs)
# Better Tracing Logic:
# 1. Use KG to find the associated river if possible
kg = KgStore(KG_STORE_DIR)
link = kg.associated_river_for_lake(lake_id)
start_river_id = None
if link and link.get('river_id'):
try:
# Handle cases like "30002024.0" or "30002024"
start_river_id = int(float(link['river_id']))
except (ValueError, TypeError):
pass
# 2. If no KG link or ID mismatch, find the nearest river segment
if start_river_id is None or start_river_id not in rivers['river_id'].values:
# Spatial fallback: Find nearest segment
distances = rivers.geometry.distance(lake_geom)
nearest_idx = distances.idxmin()
start_river_id = rivers.loc[nearest_idx, 'river_id']
print(f"Using nearest river segment fallback: {start_river_id}")
else:
print(f"Using KG-linked river segment: {start_river_id}")
# 3. Trace downstream using 'downstream_line_ids' topology
def trace_downstream(df, start_id, max_dist=50000):
path_ids = []
curr_id = start_id
total_dist = 0
# Pre-filter for performance: create a lookup dictionary
river_lookup = df.set_index('river_id')[['downstream_line_ids', 'geometry']].to_dict('index')
while curr_id in river_lookup and total_dist < max_dist:
if curr_id in path_ids: break # Cycle detection
path_ids.append(curr_id)
row = river_lookup[curr_id]
total_dist += row['geometry'].length
next_ids_str = str(row['downstream_line_ids'])
if not next_ids_str or next_ids_str == 'None' or next_ids_str == '':
break
try:
# Take first downstream branch (primary flow path)
curr_id = int(float(next_ids_str.split(',')[0]))
except (ValueError, TypeError):
break
return path_ids
path_ids = trace_downstream(rivers, start_river_id)
potential_path = rivers[rivers['river_id'].isin(path_ids)]
if potential_path.empty:
flood_zone = lake_geom.buffer(2000)
path_desc = "Radial 2km hazard zone (no rivers found nearby)."
else:
# Create a 1km hazard zone along connected rivers
union_rivers = potential_path.union_all() if hasattr(potential_path, 'union_all') else potential_path.unary_union
flood_zone = union_rivers.buffer(1000)
path_desc = f"Downstream path along {len(potential_path)} topological river segments (1km hazard buffer)."
# 4. Identify Buildings at Risk (CRITICAL CRS FIX)
# Bbox for read_file MUST match the file's CRS (EPSG:4326)
flood_zone_4326 = gpd.GeoSeries([flood_zone], crs=metric_crs).to_crs("EPSG:4326").iloc[0]
bbox = flood_zone_4326.bounds
print(f"Loading buildings in bbox: {bbox}")
buildings = gpd.read_file(BUILDINGS_PATH, bbox=bbox)
if buildings.empty:
return f"--- GLOF IMPACT SIMULATION REPORT: {lake_id} ---\nEstimated Qmax: {q_max:.2f} m3/s\nNote: No buildings found in the mapped {path_desc}"
if buildings.crs != metric_crs:
buildings = buildings.to_crs(metric_crs)
at_risk = buildings[buildings.geometry.intersects(flood_zone)]
report = [
f"--- GLOF IMPACT SIMULATION REPORT: {lake_id} ---",
f"Estimated Peak Discharge (Qmax): {q_max:.2f} m3/s",
f"Estimated Lake Volume: {est_volume:,.0f} m3",
f"Hazard Path: {path_desc}",
f"Total Buildings at Risk: {len(at_risk)} structure(s).",
]
if not at_risk.empty:
if 'type' in at_risk.columns:
types = at_risk['type'].fillna('unknown').value_counts().to_dict()
type_str = ", ".join([f"{count} {t}" for t, count in types.items()])
report.append(f"Inundated Structures: {type_str}")
# Distance to nearest building
min_dist = at_risk.geometry.distance(lake_geom).min()
report.append(f"Nearest structure is {min_dist/1000:.2f} km downstream.")
return "\n".join(report)
except Exception as e:
import traceback
return f"Simulation Error: {str(e)}\n{traceback.format_exc()}"
def query_knowledge_graph(query: str) -> str:
"""
Use this tool to search the semantic Knowledge Graph for definitions, concepts, triggers, risks, thresholds,
and spatial topology (e.g. river catchments, sub-catchments, and nearest rivers).
Use this when the user asks about "thresholds", "causes", "hazard index", "weather variables", "catchments", or ontological classes.
Input: The search query string.
"""
try:
kg = KgStore(KG_STORE_DIR)
context = kg.context_for_question(query)
if not context:
return "No relevant information found in the Knowledge Graph for this query."
return context
except Exception as e:
return f"KG Error: {str(e)}"
# Dictionary mapping tool names to functions for the Agent loop
TOOLS = {
"get_dataset_info": get_dataset_info,
"execute_python_code": execute_python_code,
"query_lakes_by_subcatchment": query_lakes_by_subcatchment,
"query_lake_properties_kg": query_lake_properties_kg,
"query_downstream_rivers_kg": query_downstream_rivers_kg,
"query_knowledge_graph": query_knowledge_graph,
"search_scientific_papers": search_scientific_papers,
"get_glof_risk_forecast": get_glof_risk_forecast,
"get_regional_glof_forecast": get_regional_glof_forecast,
"simulate_glof_flood_impact": simulate_glof_flood_impact,
"detect_lakes_and_calculate_area": detect_lakes_and_calculate_area
}
def get_tool_descriptions() -> str:
"""Helper to inject tool descriptions into the prompt."""
desc = []
for name, func in TOOLS.items():
desc.append(f"Tool Name: {name}\nDescription: {func.__doc__.strip()}\n")
return "\n---\n".join(desc)