| import sys |
| import torch |
| import os |
| import math |
| from datetime import date, datetime |
| from pathlib import Path |
| import warnings |
| import contextlib |
| import io |
| import re |
|
|
| |
| warnings.filterwarnings("ignore") |
|
|
| |
| 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}") |
|
|
| |
| 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 |
| |
| 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" |
|
|
| |
| 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" |
|
|
| |
| CKPT_PATH = PROJECT_ROOT / "models" / "lake_segmentation" / "best_model.pth" |
| DETECTION_OUT_DIR = PROJECT_ROOT / "agent_detection_outputs" |
|
|
| |
| 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) |
|
|
| |
| 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. |
| """ |
| |
| code = code.strip() |
| if code.startswith("```python"): |
| code = code[9:] |
| elif code.startswith("```"): |
| code = code[3:] |
| if code.endswith("```"): |
| code = code[:-3] |
| |
| |
| output_buffer = io.StringIO() |
| try: |
| with contextlib.redirect_stdout(output_buffer): |
| |
| 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: |
| |
| sid_str = subcatchment_id.strip().replace("'", "").replace('"', "") |
| sid = float(sid_str) |
| lakes = gpd.read_file(LAKES_PATH) |
| |
| 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}." |
| |
| |
| 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'). |
| """ |
| |
| top_k = 5 |
| alpha = 0.65 |
| if is_tabular_query(query): |
| top_k = 10 |
| alpha = 0.45 |
| 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" |
| |
| |
| 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] |
|
|
| |
| 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'." |
|
|
| |
| 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 |
| |
| |
| 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: |
| |
| 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 |
| |
| |
| |
| 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: |
| |
| 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)" |
| |
| |
| q_max = 0.0048 * (est_volume ** 0.896) |
| |
| |
| rivers = gpd.read_file(RIVERS_PATH) |
| if rivers.crs != metric_crs: |
| rivers = rivers.to_crs(metric_crs) |
| |
| |
| |
| 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: |
| |
| start_river_id = int(float(link['river_id'])) |
| except (ValueError, TypeError): |
| pass |
| |
| |
| if start_river_id is None or start_river_id not in rivers['river_id'].values: |
| |
| 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}") |
|
|
| |
| def trace_downstream(df, start_id, max_dist=50000): |
| path_ids = [] |
| curr_id = start_id |
| total_dist = 0 |
| |
| 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 |
| 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: |
| |
| 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: |
| |
| 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)." |
| |
| |
| |
| 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}") |
| |
| |
| 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)}" |
|
|
| |
| 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) |
|
|