Spaces:
Sleeping
Sleeping
| """ | |
| gee_client.py β Chronostasis Real GEE Data Client | |
| =================================================== | |
| Provides real Sentinel-1 SAR flood analysis for ANY point in India | |
| using the GEE Python API with service account authentication. | |
| All functions work with coordinates so the user can query any location, | |
| not just the 15 hardcoded basins. | |
| """ | |
| import json | |
| import os | |
| import time | |
| from typing import Any, Dict, List, Optional, Tuple | |
| try: | |
| import ee | |
| EE_OK = True | |
| except ImportError: | |
| EE_OK = False | |
| # ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _GEE_INITIALIZED = False | |
| def init_gee(project: str = None, sa_json: str = None) -> bool: | |
| global _GEE_INITIALIZED | |
| if _GEE_INITIALIZED: | |
| return True | |
| if not EE_OK: | |
| return False | |
| try: | |
| sa_json = sa_json or os.getenv("GEE_SERVICE_ACCOUNT_JSON") | |
| project = project or os.getenv("GEE_PROJECT", "chronostasis-gee") | |
| if sa_json: | |
| key = sa_json if isinstance(sa_json, dict) else json.loads(sa_json) | |
| creds = ee.ServiceAccountCredentials( | |
| email=key["client_email"], key_data=json.dumps(key)) | |
| ee.Initialize(creds, project=project) | |
| else: | |
| ee.Initialize(project=project) | |
| _GEE_INITIALIZED = True | |
| print("[GEE] Initialized successfully", flush=True) | |
| return True | |
| except Exception as e: | |
| print(f"[GEE] Init failed: {e}", flush=True) | |
| return False | |
| def gee_available() -> bool: | |
| return _GEE_INITIALIZED and EE_OK | |
| # ββ Core helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_aoi(lat: float, lon: float, radius_km: float = 100) -> Any: | |
| """Create circular AOI around a point.""" | |
| return ee.Geometry.Point([lon, lat]).buffer(radius_km * 1000) | |
| def make_rect_aoi(min_lon: float, min_lat: float, | |
| max_lon: float, max_lat: float) -> Any: | |
| return ee.Geometry.Rectangle([min_lon, min_lat, max_lon, max_lat]) | |
| def _get_s1_monsoon(aoi, year: int): | |
| """Sentinel-1 SAR VV descending, monsoon season (Jun-Sep).""" | |
| return (ee.ImageCollection("COPERNICUS/S1_GRD") | |
| .filterBounds(aoi) | |
| .filterDate(f"{year}-06-01", f"{year}-09-30") | |
| .filter(ee.Filter.eq("instrumentMode", "IW")) | |
| .filter(ee.Filter.listContains("transmitterReceiverPolarisation", "VV")) | |
| .filter(ee.Filter.eq("orbitProperties_pass", "DESCENDING")) | |
| .select("VV") | |
| .median()) | |
| def _get_s1_dry(aoi, year: int): | |
| """Sentinel-1 SAR VV, dry season baseline (Nov-Mar).""" | |
| return (ee.ImageCollection("COPERNICUS/S1_GRD") | |
| .filterBounds(aoi) | |
| .filterDate(f"{year-1}-11-01", f"{year}-03-31") | |
| .filter(ee.Filter.eq("instrumentMode", "IW")) | |
| .filter(ee.Filter.listContains("transmitterReceiverPolarisation", "VV")) | |
| .filter(ee.Filter.eq("orbitProperties_pass", "DESCENDING")) | |
| .select("VV") | |
| .median()) | |
| def _get_perm_water(aoi): | |
| """Permanent water mask from Landsat 8 NDWI.""" | |
| def add_ndwi(img): | |
| nir = img.select("SR_B5").multiply(0.0000275).add(-0.2) | |
| green = img.select("SR_B3").multiply(0.0000275).add(-0.2) | |
| return nir.subtract(green).divide(nir.add(green)).rename("NDWI") | |
| return (ee.ImageCollection("LANDSAT/LC08/C02/T1_L2") | |
| .filterBounds(aoi) | |
| .filterDate("2020-01-01", "2022-05-31") | |
| .filter(ee.Filter.lt("CLOUD_COVER", 20)) | |
| .map(add_ndwi) | |
| .median() | |
| .gt(0.3)) | |
| def _flood_mask(sar_monsoon, perm_water, threshold: float = -16): | |
| """Clean flood mask: SAR below threshold, not permanent water.""" | |
| raw = sar_monsoon.lt(threshold).And(perm_water.Not()) | |
| return (raw | |
| .focal_min(radius=1, kernelType="square", units="pixels") | |
| .focal_max(radius=1, kernelType="square", units="pixels")) | |
| def _compute_area_km2(mask, aoi, scale: int = 100) -> float: | |
| """Returns area of mask in kmΒ².""" | |
| area = (mask | |
| .multiply(ee.Image.pixelArea().divide(1e6)) | |
| .reduceRegion( | |
| reducer=ee.Reducer.sum(), | |
| geometry=aoi, | |
| scale=scale, | |
| maxPixels=1e10)) | |
| result = area.getInfo() | |
| # VV is the band name for SAR-derived masks | |
| for key in ["VV", "constant", "NDWI"]: | |
| if key in result and result[key] is not None: | |
| return round(float(result[key]), 2) | |
| # Fallback β first numeric value | |
| for v in result.values(): | |
| if v is not None: | |
| try: | |
| return round(float(v), 2) | |
| except: | |
| pass | |
| return 0.0 | |
| # ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_flood_stats(lat: float, lon: float, | |
| radius_km: float = 100, | |
| threshold: float = -16, | |
| timeout: int = 60) -> Dict[str, Any]: | |
| """ | |
| Returns real SAR flood stats for any lat/lon point in India. | |
| Returns: | |
| { | |
| "flood_areas_km2": {2022: float, 2023: float, 2024: float}, | |
| "peak_year": int, | |
| "chronic_km2": float, | |
| "lat": float, "lon": float, | |
| "radius_km": float, | |
| } | |
| """ | |
| if not gee_available(): | |
| return {"error": "GEE not available", "mock": True} | |
| try: | |
| aoi = make_aoi(lat, lon, radius_km) | |
| perm_w = _get_perm_water(aoi) | |
| flood_areas = {} | |
| for year in [2022, 2023, 2024]: | |
| sar = _get_s1_monsoon(aoi, year) | |
| flood = _flood_mask(sar, perm_w, threshold) | |
| area = _compute_area_km2(flood, aoi) | |
| flood_areas[year] = area | |
| # Chronic = flooded all 3 years | |
| floods = {} | |
| for year in [2022, 2023, 2024]: | |
| sar = _get_s1_monsoon(aoi, year) | |
| floods[year] = _flood_mask(sar, perm_w, threshold) | |
| chronic = floods[2022].And(floods[2023]).And(floods[2024]) | |
| chronic_km2 = _compute_area_km2(chronic, aoi) | |
| peak_year = max(flood_areas, key=flood_areas.get) | |
| return { | |
| "flood_areas_km2": flood_areas, | |
| "peak_year": peak_year, | |
| "chronic_km2": chronic_km2, | |
| "lat": lat, | |
| "lon": lon, | |
| "radius_km": radius_km, | |
| "sar_threshold_db": threshold, | |
| "mock": False, | |
| } | |
| except Exception as e: | |
| print(f"[GEE] get_flood_stats error: {e}", flush=True) | |
| return {"error": str(e), "mock": True} | |
| def get_risk_zones(lat: float, lon: float, | |
| radius_km: float = 100) -> Dict[str, Any]: | |
| """ | |
| Returns multi-factor flood risk zone areas (high/moderate/low) for any point. | |
| Uses: flood frequency + CHIRPS rainfall + DEM elevation + HydroSHEDS flow | |
| """ | |
| if not gee_available(): | |
| return {"error": "GEE not available", "mock": True} | |
| try: | |
| aoi = make_aoi(lat, lon, radius_km) | |
| perm_w = _get_perm_water(aoi) | |
| # Flood frequency (0-3) | |
| floods = {} | |
| for year in [2022, 2023, 2024]: | |
| sar = _get_s1_monsoon(aoi, year) | |
| floods[year] = _flood_mask(sar, perm_w) | |
| freq = floods[2022].add(floods[2023]).add(floods[2024]) | |
| # Terrain | |
| dem = ee.Image("USGS/SRTMGL1_003").clip(aoi) | |
| flow_acc = ee.Image("WWF/HydroSHEDS/15ACC").select("b1").clip(aoi) | |
| # CHIRPS rainfall | |
| chirps = (ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY") | |
| .filterBounds(aoi) | |
| .filterDate("2022-06-01", "2022-09-30") | |
| .sum() | |
| .clip(aoi)) | |
| # Normalise | |
| freq_n = freq.divide(3) | |
| rain_n = chirps.subtract(400).divide(1400).clamp(0, 1) | |
| elev_n = ee.Image(1).subtract(dem.divide(500).clamp(0, 1)) | |
| flow_n = flow_acc.log().divide(12).clamp(0, 1) | |
| # Weighted risk score | |
| risk = (freq_n.multiply(0.35) | |
| .add(rain_n.multiply(0.25)) | |
| .add(elev_n.multiply(0.20)) | |
| .add(flow_n.multiply(0.20))) | |
| high_mask = risk.gt(0.65) | |
| mod_mask = risk.gt(0.35).And(risk.lte(0.65)) | |
| low_mask = risk.lte(0.35) | |
| pix = ee.Image.pixelArea().divide(1e6) | |
| high_km2 = _compute_area_km2(high_mask, aoi) | |
| mod_km2 = _compute_area_km2(mod_mask, aoi) | |
| low_km2 = _compute_area_km2(low_mask, aoi) | |
| return { | |
| "risk_zones_km2": { | |
| "high": high_km2, | |
| "moderate": mod_km2, | |
| "low": low_km2, | |
| }, | |
| "lat": lat, | |
| "lon": lon, | |
| "radius_km": radius_km, | |
| "mock": False, | |
| } | |
| except Exception as e: | |
| print(f"[GEE] get_risk_zones error: {e}", flush=True) | |
| return {"error": str(e), "mock": True} | |
| def get_flood_tile_url(lat: float, lon: float, | |
| year: int = 2022, | |
| radius_km: float = 200) -> Dict[str, Any]: | |
| """ | |
| Returns a GEE map tile URL for flood risk visualization. | |
| Use with Leaflet: L.tileLayer(tile_url).addTo(map) | |
| """ | |
| if not gee_available(): | |
| return {"error": "GEE not available", "mock": True} | |
| try: | |
| aoi = make_aoi(lat, lon, radius_km) | |
| perm_w = _get_perm_water(aoi) | |
| # Per-year flood masks | |
| flood_layers = {} | |
| for y in [2022, 2023, 2024]: | |
| sar = _get_s1_monsoon(aoi, y) | |
| flood_layers[y] = _flood_mask(sar, perm_w) | |
| chronic = flood_layers[2022].And(flood_layers[2023]).And(flood_layers[2024]) | |
| freq = flood_layers[2022].add(flood_layers[2023]).add(flood_layers[2024]) | |
| # Terrain for risk overlay | |
| dem = ee.Image("USGS/SRTMGL1_003").clip(aoi) | |
| flow_acc = ee.Image("WWF/HydroSHEDS/15ACC").select("b1").clip(aoi) | |
| chirps = (ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY") | |
| .filterBounds(aoi).filterDate(f"{year}-06-01", f"{year}-09-30") | |
| .sum().clip(aoi)) | |
| freq_n = freq.divide(3) | |
| rain_n = chirps.subtract(400).divide(1400).clamp(0, 1) | |
| elev_n = ee.Image(1).subtract(dem.divide(500).clamp(0, 1)) | |
| flow_n = flow_acc.log().divide(12).clamp(0, 1) | |
| risk = (freq_n.multiply(0.35).add(rain_n.multiply(0.25)) | |
| .add(elev_n.multiply(0.20)).add(flow_n.multiply(0.20))) | |
| risk_vis = risk.visualize( | |
| min=0, max=1, | |
| palette=["00aa00", "ffff00", "ff8800", "ff0000"]) | |
| flood_vis = flood_layers[year].selfMask().visualize( | |
| palette=["0044ff"]) | |
| chronic_vis = chronic.selfMask().visualize( | |
| palette=["ff0000"]) | |
| # Get tile URLs | |
| risk_map = risk_vis.getMapId() | |
| flood_map = flood_vis.getMapId() | |
| chronic_map = chronic_vis.getMapId() | |
| def tile_url(map_id_obj): | |
| return (f"https://earthengine.googleapis.com/v1/" | |
| f"{map_id_obj['mapid']}/tiles/{{z}}/{{x}}/{{y}}") | |
| return { | |
| "tiles": { | |
| "risk": tile_url(risk_map), | |
| "flood": tile_url(flood_map), | |
| "chronic": tile_url(chronic_map), | |
| }, | |
| "year": year, | |
| "lat": lat, | |
| "lon": lon, | |
| "mock": False, | |
| } | |
| except Exception as e: | |
| print(f"[GEE] get_flood_tile_url error: {e}", flush=True) | |
| return {"error": str(e), "mock": True} | |
| def get_chirps_rainfall(lat: float, lon: float, | |
| year: int, radius_km: float = 100) -> float: | |
| """Returns total monsoon season CHIRPS rainfall in mm.""" | |
| if not gee_available(): | |
| return 0.0 | |
| try: | |
| aoi = make_aoi(lat, lon, radius_km) | |
| chirps = (ee.ImageCollection("UCSB-CHG/CHIRPS/DAILY") | |
| .filterBounds(aoi) | |
| .filterDate(f"{year}-06-01", f"{year}-09-30") | |
| .sum().clip(aoi)) | |
| result = chirps.reduceRegion( | |
| reducer=ee.Reducer.mean(), | |
| geometry=aoi, scale=5000, maxPixels=1e9).getInfo() | |
| val = result.get("precipitation", result.get("b1", 0)) | |
| return round(float(val or 0), 1) | |
| except Exception as e: | |
| print(f"[GEE] CHIRPS error: {e}", flush=True) | |
| return 0.0 | |
| def query_any_location(lat: float, lon: float, | |
| radius_km: float = 80) -> Dict[str, Any]: | |
| """ | |
| Master function β queries real GEE data for any lat/lon. | |
| Returns everything needed to populate a dynamic region. | |
| """ | |
| if not gee_available(): | |
| return {"error": "GEE not available", "mock": True, | |
| "lat": lat, "lon": lon} | |
| try: | |
| flood_stats = get_flood_stats(lat, lon, radius_km) | |
| risk_zones = get_risk_zones(lat, lon, radius_km) | |
| rainfall = get_chirps_rainfall(lat, lon, 2022, radius_km) | |
| tile_urls = get_flood_tile_url(lat, lon, 2022, radius_km * 2) | |
| return { | |
| "lat": lat, | |
| "lon": lon, | |
| "radius_km": radius_km, | |
| "flood_areas_km2": flood_stats.get("flood_areas_km2", {}), | |
| "peak_year": flood_stats.get("peak_year", 2022), | |
| "chronic_km2": flood_stats.get("chronic_km2", 0), | |
| "risk_zones_km2": risk_zones.get("risk_zones_km2", {}), | |
| "peak_rainfall_mm": rainfall, | |
| "tiles": tile_urls.get("tiles", {}), | |
| "mock": False, | |
| } | |
| except Exception as e: | |
| print(f"[GEE] query_any_location error: {e}", flush=True) | |
| return {"error": str(e), "mock": True, | |
| "lat": lat, "lon": lon} | |
| # ββ Mock fallback (when GEE not available) ββββββββββββββββββββββββββββββββ | |
| MOCK_STATS = { | |
| "flood_areas_km2": {2022: 4812.3, 2023: 3601.7, 2024: 4102.8}, | |
| "peak_year": 2022, | |
| "chronic_km2": 1823.4, | |
| "risk_zones_km2": {"high": 3218.4, "moderate": 5901.2, "low": 8234.7}, | |
| "peak_rainfall_mm": 1587, | |
| "tiles": {}, | |
| "mock": True, | |
| } | |
| def get_stats_or_mock(lat: float, lon: float, | |
| radius_km: float = 80) -> Dict[str, Any]: | |
| """Returns real GEE stats or mock data if GEE unavailable.""" | |
| if gee_available(): | |
| return query_any_location(lat, lon, radius_km) | |
| return {**MOCK_STATS, "lat": lat, "lon": lon, "radius_km": radius_km} | |