adema5051 commited on
Commit
a359779
·
verified ·
1 Parent(s): 7a93f4c

Upload 10 files

Browse files
Files changed (10) hide show
  1. .dockerignore +48 -0
  2. Dockerfile +31 -0
  3. README.md +28 -5
  4. explainability.py +163 -0
  5. gee_auth.py +56 -0
  6. main.py +602 -0
  7. requirements.txt +17 -0
  8. runtime.txt +1 -0
  9. spatial_queries.py +754 -0
  10. vulnerability.py +507 -0
.dockerignore ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ *.so
7
+ *.egg
8
+ *.egg-info
9
+ dist
10
+ build
11
+ .git
12
+ .gitignore
13
+ .github
14
+ README.md
15
+ *.md
16
+ .env
17
+ .venv
18
+ venv/
19
+ ENV/
20
+
21
+ # Logs and cache
22
+ *.log
23
+ logs/
24
+ cache/
25
+ *.pkl
26
+
27
+ # IDE
28
+ .vscode
29
+ .idea
30
+ *.swp
31
+ *.swo
32
+ *~
33
+
34
+ # Testing
35
+ .pytest_cache
36
+ .coverage
37
+ htmlcov/
38
+ .tox/
39
+
40
+ # OS
41
+ .DS_Store
42
+ Thumbs.db
43
+
44
+ # Local credentials (use Cloud Run secrets instead)
45
+ gee-service-account.json
46
+
47
+ # Git
48
+ .gitattributes
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies for geopandas + GDAL
6
+ RUN apt-get update && apt-get install -y \
7
+ gdal-bin \
8
+ libgdal-dev \
9
+ g++ \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Copy requirements first for better layer caching
13
+ COPY requirements.txt .
14
+
15
+ # Install Python dependencies
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy application code
19
+ COPY . .
20
+
21
+ # Create directories for logs (ephemeral but prevents errors)
22
+ RUN mkdir -p logs
23
+
24
+ # Cloud Run injects PORT environment variable
25
+ ENV PORT=8080
26
+
27
+ # Expose port for documentation
28
+ EXPOSE 8080
29
+
30
+ # Single worker - your ONNX models are too large for multiple workers
31
+ CMD exec uvicorn main:app --host 0.0.0.0 --port ${PORT} --workers 1 --timeout-keep-alive 300
README.md CHANGED
@@ -1,10 +1,33 @@
1
  ---
2
- title: Flood Vulnerability Api
3
- emoji: 🏃
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Flood Vulnerability API
3
+ emoji: 🌊
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # Flood Vulnerability Assessment API
11
+
12
+ Global, real-time flood risk analysis powered by:
13
+ - Google Earth Engine (terrain)
14
+ - OpenStreetMap (water proximity)
15
+ - SHAP (explanations)
16
+ - Multi-hazard modeling (fluvial, coastal, pluvial)
17
+
18
+ ## Features
19
+ - Batch CSV upload
20
+ - 95% CI + uncertainty
21
+ - Multi Hazard detection (Fluvial, Coastal Surge and Pluvial)
22
+
23
+ ## Try It
24
+ 1. Visit `/docs` for interactive API documentation
25
+ 2. Example coordinates:
26
+ - `29.17, -95.31` → **MODERATE**
27
+ - `27.7, 86.7` → **LOW**
28
+
29
+ ## Tech Stack
30
+ - FastAPI + Hugging Face Spaces
31
+ - GEE + OSM + Natural Earth
32
+ - ONNX models for predictions
33
+ - `@lru_cache` for 100x batch speed
explainability.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # explainability.py
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ from sklearn.ensemble import RandomForestRegressor
6
+ import shap
7
+ import pickle
8
+ import os
9
+
10
+
11
+ class VulnerabilityExplainer:
12
+ """
13
+ SHAP-based explainer for flood vulnerability scores
14
+ """
15
+
16
+ def __init__(self, model_path='models/rf_explainer.pkl'):
17
+ self.model = None
18
+ self.explainer = None
19
+ self.model_path = model_path
20
+ self.feature_names = [
21
+ 'proximity_score',
22
+ 'tpi_score',
23
+ 'slope_score',
24
+ 'height_score',
25
+ 'elevation'
26
+
27
+ ]
28
+
29
+ def train(self, training_data_path='training_data.csv'):
30
+ """
31
+ Train surrogate RF model on existing vulnerability assessments
32
+ """
33
+ print(f"Loading training data from {training_data_path}...")
34
+ df = pd.read_csv(training_data_path)
35
+
36
+ missing_cols = [col for col in self.feature_names if col not in df.columns]
37
+ if missing_cols:
38
+ raise ValueError(f"Missing columns in training data: {missing_cols}")
39
+
40
+ if 'vulnerability_index' not in df.columns:
41
+ raise ValueError("Training data must have 'vulnerability_index' column")
42
+
43
+ X = df[self.feature_names]
44
+ y = df['vulnerability_index']
45
+
46
+ print(f"Training Random Forest on {len(df)} samples...")
47
+
48
+ self.model = RandomForestRegressor(
49
+ n_estimators=100,
50
+ max_depth=10,
51
+ random_state=42,
52
+ n_jobs=-1
53
+ )
54
+ self.model.fit(X, y)
55
+
56
+ print("Creating SHAP explainer...")
57
+ self.explainer = shap.TreeExplainer(self.model)
58
+
59
+ os.makedirs(os.path.dirname(self.model_path), exist_ok=True)
60
+ with open(self.model_path, 'wb') as f:
61
+ pickle.dump({
62
+ 'model': self.model,
63
+ 'explainer': self.explainer,
64
+ 'feature_names': self.feature_names
65
+ }, f)
66
+
67
+ r2_score = self.model.score(X, y)
68
+ print(f"✅ Model trained successfully!")
69
+ print(f" R² score: {r2_score:.3f}")
70
+ print(f" Saved to: {self.model_path}")
71
+
72
+ def load(self):
73
+ """Load trained model"""
74
+ if os.path.exists(self.model_path):
75
+ try:
76
+ with open(self.model_path, 'rb') as f:
77
+ data = pickle.load(f)
78
+ self.model = data['model']
79
+ self.explainer = data['explainer']
80
+ self.feature_names = data['feature_names']
81
+ print(f"✅ SHAP model loaded from {self.model_path}")
82
+ return True
83
+ except Exception as e:
84
+ print(f"⚠️ Failed to load SHAP model: {e}")
85
+ return False
86
+ else:
87
+ print(f"⚠️ SHAP model not found at {self.model_path}")
88
+ return False
89
+
90
+ def explain(self, features_dict):
91
+ """
92
+ Generate SHAP explanation for a single assessment
93
+ """
94
+ if not self.explainer:
95
+ if not self.load():
96
+ return None
97
+
98
+ try:
99
+ X = pd.DataFrame([features_dict])[self.feature_names]
100
+ except KeyError as e:
101
+ print(f"Missing feature in input: {e}")
102
+ return None
103
+
104
+ shap_values = self.explainer.shap_values(X)
105
+ if isinstance(shap_values, list):
106
+ shap_values = shap_values[0]
107
+
108
+ shap_values = np.array(shap_values).astype(float).flatten()
109
+ base_value = float(np.array(self.explainer.expected_value).mean())
110
+
111
+ contributions = list(zip(self.feature_names, shap_values))
112
+ contributions.sort(key=lambda x: abs(x[1]), reverse=True)
113
+ total_impact = sum(abs(v) for _, v in contributions)
114
+
115
+ explanations = []
116
+ for name, value in contributions:
117
+ value = float(value)
118
+ pct = (abs(value) / total_impact * 100) if total_impact > 0 else 0
119
+ direction = "increases" if value > 0 else "decreases"
120
+ explanations.append({
121
+ 'factor': self._humanize_feature(name),
122
+ 'contribution_pct': round(pct, 1),
123
+ 'direction': direction,
124
+ 'shap_value': round(value, 3)
125
+ })
126
+
127
+ return {
128
+ 'base_vulnerability': round(base_value, 3),
129
+ 'predicted_vulnerability': round(base_value + sum(shap_values), 3),
130
+ 'explanations': explanations,
131
+ 'top_risk_driver': explanations[0]['factor'] if explanations else None
132
+ }
133
+
134
+ def _humanize_feature(self, feature_name):
135
+ """Convert feature names to readable descriptions"""
136
+ labels = {
137
+ 'proximity_score': 'Distance to water',
138
+ 'tpi_score': 'Topographic position (valley vs. ridge)',
139
+ 'slope_score': 'Terrain slope',
140
+ 'height_score': 'Building height and basement',
141
+ 'elevation': 'Elevation above sea level'
142
+ }
143
+ return labels.get(feature_name, feature_name)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ import sys
148
+
149
+ if len(sys.argv) > 1:
150
+ training_file = sys.argv[1]
151
+ else:
152
+ training_file = 'training_data.csv'
153
+
154
+ if not os.path.exists(training_file):
155
+ print(f"❌ Training data not found: {training_file}")
156
+
157
+ sys.exit(1)
158
+
159
+ explainer = VulnerabilityExplainer()
160
+ explainer.train(training_file)
161
+
162
+ print("\n✅ SHAP explainer ready!")
163
+
gee_auth.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ee
2
+ import os
3
+ import json
4
+
5
+ def initialize_gee():
6
+ """Initialize Google Earth Engine with service account from env"""
7
+ try:
8
+ # Local development - JSON file
9
+ if os.path.exists('gee-service-account.json'):
10
+ SERVICE_ACCOUNT = 'gee-access@gee-research-project.iam.gserviceaccount.com'
11
+ credentials = ee.ServiceAccountCredentials(
12
+ SERVICE_ACCOUNT,
13
+ 'gee-service-account.json'
14
+ )
15
+ ee.Initialize(credentials)
16
+ print("✅ GEE authenticated (local file)")
17
+ return True
18
+
19
+ # Cloud Run - full JSON key as secret
20
+ gee_key_json = os.getenv('GEE_SERVICE_ACCOUNT_KEY')
21
+
22
+ if gee_key_json:
23
+ # Parse JSON credentials
24
+ key_dict = json.loads(gee_key_json)
25
+ credentials = ee.ServiceAccountCredentials(
26
+ email=key_dict['client_email'],
27
+ key_data=gee_key_json
28
+ )
29
+ ee.Initialize(credentials)
30
+ print("✅ GEE authenticated (Cloud Run secret)")
31
+ return True
32
+
33
+ # Fallback to split-key format (Render compatibility)
34
+ service_account = os.getenv('GEE_SERVICE_ACCOUNT')
35
+ private_key_json = os.getenv('GEE_PRIVATE_KEY')
36
+
37
+ if service_account and private_key_json:
38
+ # Clean up private key formatting
39
+ if private_key_json.startswith('"'):
40
+ private_key = private_key_json.strip('"').replace('\\n', '\n')
41
+ else:
42
+ private_key = private_key_json.replace('\\n', '\n')
43
+
44
+ credentials = ee.ServiceAccountCredentials(
45
+ email=service_account,
46
+ key_data=private_key
47
+ )
48
+ ee.Initialize(credentials)
49
+ print("✅ GEE authenticated (split credentials)")
50
+ return True
51
+
52
+ raise ValueError("No GEE credentials found - set GEE_SERVICE_ACCOUNT_KEY or use local JSON file")
53
+
54
+ except Exception as e:
55
+ print(f"❌ GEE authentication failed: {e}")
56
+ return False
main.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py - FastAPI application for Flood Vulnerability Assessment
2
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Request
3
+ from fastapi.responses import StreamingResponse, HTMLResponse
4
+ from fastapi.templating import Jinja2Templates
5
+ from pydantic import BaseModel, field_validator
6
+ from typing import Optional, Dict
7
+ import pandas as pd
8
+ import io
9
+ import asyncio
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from contextlib import asynccontextmanager
12
+ from datetime import datetime
13
+
14
+ from spatial_queries import get_terrain_metrics, distance_to_water
15
+ from vulnerability import calculate_vulnerability_index
16
+ from gee_auth import initialize_gee
17
+ import os
18
+
19
+ DISABLE_HEIGHT_PREDICTOR = os.environ.get("DISABLE_HEIGHT", "false").lower() == "true"
20
+
21
+ # Global flags for model readiness
22
+ model_ready = False
23
+ gee_ready = False
24
+
25
+ # OSM rate limiting
26
+ _last_osm_request = None
27
+ _osm_lock = asyncio.Lock()
28
+
29
+ async def throttled_distance_to_water(lat, lon):
30
+ """
31
+ Throttle OSM requests
32
+ """
33
+ global _last_osm_request
34
+
35
+ async with _osm_lock:
36
+ if _last_osm_request:
37
+ elapsed = (datetime.now() - _last_osm_request).total_seconds()
38
+ if elapsed < 0.5: # 2 req/sec max
39
+ await asyncio.sleep(0.5 - elapsed)
40
+
41
+ loop = asyncio.get_event_loop()
42
+ result = await loop.run_in_executor(None, distance_to_water, lat, lon)
43
+ _last_osm_request = datetime.now()
44
+ return result
45
+
46
+
47
+ # Lifespan context manager - loads heavy models AFTER port binding
48
+ @asynccontextmanager
49
+ async def lifespan(app: FastAPI):
50
+ # Startup: Port binds first, models load in background
51
+ print("🚀 FastAPI server starting - port binding now")
52
+ asyncio.create_task(load_heavy_models())
53
+ yield
54
+ # Shutdown
55
+ print("🛑 Shutting down")
56
+
57
+ async def load_heavy_models():
58
+ """Load heavy models asynchronously after server starts"""
59
+ global model_ready, gee_ready
60
+
61
+ try:
62
+ # Initialize GEE immediately (no delay needed)
63
+ print("📡 Initializing GEE...")
64
+ initialize_gee()
65
+ gee_ready = True
66
+ print("✅ GEE initialized")
67
+
68
+ # Load SHAP explainer
69
+ try:
70
+ from explainability import VulnerabilityExplainer
71
+ global explainer
72
+ explainer = VulnerabilityExplainer()
73
+ print("✅ SHAP model initialized")
74
+ except Exception as e:
75
+ print(f"⚠️ SHAP explainer not available: {e}")
76
+ explainer = None
77
+
78
+ # Load height predictor (334 MB model)
79
+ print("📦 Loading height predictor...")
80
+
81
+ if DISABLE_HEIGHT_PREDICTOR:
82
+ print("⚠️ Height predictor disabled for this deployment.")
83
+ model_ready = False
84
+ else:
85
+ try:
86
+ from height_predictor.inference import get_predictor
87
+ get_predictor()
88
+ model_ready = True
89
+ print("✅ Height predictor ready")
90
+ except Exception as e:
91
+ print(f"⚠️ Height predictor failed to load: {e}")
92
+ model_ready = False
93
+
94
+ except Exception as e:
95
+ print(f"❌ Model loading failed: {e}")
96
+
97
+ # APP INITIALIZATION
98
+ app = FastAPI(
99
+ title="Flood Vulnerability Assessment API",
100
+ version="1.0",
101
+ lifespan=lifespan
102
+ )
103
+
104
+ # Frontend templates setup
105
+ templates = Jinja2Templates(directory="templates")
106
+
107
+ # Thread pool for batch processing
108
+ executor = ThreadPoolExecutor(max_workers=10)
109
+
110
+ # Initialize explainer as None (loaded during startup)
111
+ explainer = None
112
+
113
+
114
+ # DATA MODEL
115
+ class SingleAssessment(BaseModel):
116
+ latitude: float
117
+ longitude: float
118
+ height: Optional[float] = 0.0
119
+ basement: Optional[float] = 0.0
120
+
121
+ @field_validator('latitude')
122
+ @classmethod
123
+ def check_lat(cls, v: float) -> float:
124
+ if not -90 <= v <= 90:
125
+ raise ValueError('Latitude must be between -90 and 90')
126
+ return v
127
+
128
+ @field_validator('longitude')
129
+ @classmethod
130
+ def check_lon(cls, v: float) -> float:
131
+ if not -180 <= v <= 180:
132
+ raise ValueError('Longitude must be between -180 and 180')
133
+ return v
134
+
135
+ @field_validator('basement')
136
+ @classmethod
137
+ def check_basement(cls, v: float) -> float:
138
+ if v > 0:
139
+ raise ValueError('Basement height must be 0 or negative (e.g., -1, -2, -3)')
140
+ return v
141
+
142
+
143
+ # FRONTEND ROUTE
144
+ @app.get("/", response_class=HTMLResponse)
145
+ async def home(request: Request):
146
+ """Serve the main web interface"""
147
+ return templates.TemplateResponse("index.html", {"request": request})
148
+
149
+
150
+ # API ROUTES
151
+ @app.get("/api")
152
+ async def root() -> Dict:
153
+ """API info endpoint"""
154
+ return {
155
+ "service": "Flood Vulnerability Assessment API",
156
+ "version": "1.0",
157
+ "endpoints": {
158
+ "POST /assess": "Assess single location",
159
+ "POST /assess_batch": "Assess batch from CSV file",
160
+ "GET /health": "Health check"
161
+ }
162
+ }
163
+
164
+
165
+ @app.get("/health")
166
+ async def health_check() -> Dict:
167
+ """Health check endpoint - responds immediately even if models still loading"""
168
+ return {
169
+ "status": "healthy",
170
+ "gee_initialized": gee_ready,
171
+ "height_predictor_ready": model_ready
172
+ }
173
+
174
+
175
+ @app.post("/assess")
176
+ async def assess_single(data: SingleAssessment) -> Dict:
177
+ """Assess flood vulnerability for a single location (non-blocking)."""
178
+ if not gee_ready:
179
+ raise HTTPException(
180
+ status_code=503,
181
+ detail="GEE still initializing, try again in 10 seconds"
182
+ )
183
+
184
+ loop = asyncio.get_event_loop()
185
+
186
+ try:
187
+ # Run terrain query in background thread
188
+ terrain = await loop.run_in_executor(
189
+ None,
190
+ get_terrain_metrics,
191
+ data.latitude,
192
+ data.longitude
193
+ )
194
+
195
+ # Throttled water distance query
196
+ water_dist = await throttled_distance_to_water(data.latitude, data.longitude)
197
+
198
+ # Calculate vulnerability after terrain + water distance retrieved
199
+ result = calculate_vulnerability_index(
200
+ lat=data.latitude,
201
+ lon=data.longitude,
202
+ height=data.height,
203
+ basement=data.basement,
204
+ terrain_metrics=terrain,
205
+ water_distance=water_dist
206
+ )
207
+
208
+ return {
209
+ "status": "success",
210
+ "input": data.dict(),
211
+ "assessment": result
212
+ }
213
+
214
+ except Exception as e:
215
+ raise HTTPException(status_code=500, detail=f"Assessment failed: {e}")
216
+
217
+
218
+ async def process_single_row_async(row, use_predicted_height: bool = False):
219
+ """Process a single row from CSV with async throttling."""
220
+ try:
221
+ lat = row['latitude']
222
+ lon = row['longitude']
223
+ height = row.get('height', 0.0)
224
+ basement = row.get('basement', 0.0)
225
+
226
+ if use_predicted_height:
227
+ if not model_ready:
228
+ raise ValueError("Height predictor not ready yet")
229
+ try:
230
+ from height_predictor.inference import get_predictor
231
+ predictor = get_predictor()
232
+ pred = predictor.predict_from_coordinates(lat, lon)
233
+ if pred.get("status") == "success" and pred.get("predicted_height") is not None:
234
+ height = float(pred["predicted_height"])
235
+ except Exception as e:
236
+ raise ValueError(f"Height prediction failed for ({lat}, {lon}): {e}")
237
+
238
+ # Run terrain in thread pool
239
+ loop = asyncio.get_event_loop()
240
+ terrain = await loop.run_in_executor(None, get_terrain_metrics, lat, lon)
241
+
242
+ # Throttled water distance
243
+ water_dist = await throttled_distance_to_water(lat, lon)
244
+
245
+ result = calculate_vulnerability_index(
246
+ lat=lat,
247
+ lon=lon,
248
+ height=height,
249
+ basement=basement,
250
+ terrain_metrics=terrain,
251
+ water_distance=water_dist
252
+ )
253
+
254
+ # CSV output - essential columns
255
+ return {
256
+ 'latitude': lat,
257
+ 'longitude': lon,
258
+ 'height': height,
259
+ 'basement': basement,
260
+ 'vulnerability_index': result['vulnerability_index'],
261
+ 'ci_lower_95': result['confidence_interval']['lower_bound_95'],
262
+ 'ci_upper_95': result['confidence_interval']['upper_bound_95'],
263
+ 'risk_level': result['risk_level'],
264
+ 'confidence': result['uncertainty_analysis']['confidence'],
265
+ 'confidence_interpretation': result['uncertainty_analysis']['interpretation'],
266
+ 'elevation_m': result['elevation_m'],
267
+ 'tpi_m': result['relative_elevation_m'],
268
+ 'slope_degrees': result['slope_degrees'],
269
+ 'distance_to_water_m': result['distance_to_water_m'],
270
+ 'quality_flags': ','.join(result['uncertainty_analysis']['data_quality_flags']) if result['uncertainty_analysis']['data_quality_flags'] else ''
271
+ }
272
+
273
+ except Exception as e:
274
+ return {
275
+ 'latitude': row.get('latitude'),
276
+ 'longitude': row.get('longitude'),
277
+ 'height': row.get('height', 0.0),
278
+ 'basement': row.get('basement', 0.0),
279
+ 'error': str(e),
280
+ 'vulnerability_index': None,
281
+ 'ci_lower_95': None,
282
+ 'ci_upper_95': None,
283
+ 'risk_level': None,
284
+ 'confidence': None,
285
+ 'confidence_interpretation': None,
286
+ 'elevation_m': None,
287
+ 'tpi_m': None,
288
+ 'slope_degrees': None,
289
+ 'distance_to_water_m': None,
290
+ 'quality_flags': ''
291
+ }
292
+
293
+
294
+ @app.post("/assess_batch")
295
+ async def assess_batch(file: UploadFile = File(...), use_predicted_height: bool = False) -> StreamingResponse:
296
+ """Assess flood vulnerability for multiple locations from a CSV file."""
297
+ if not gee_ready:
298
+ raise HTTPException(
299
+ status_code=503,
300
+ detail="GEE still initializing, try again in 10 seconds"
301
+ )
302
+
303
+ if use_predicted_height and not model_ready:
304
+ raise HTTPException(
305
+ status_code=503,
306
+ detail="Height predictor still loading, try again in 30 seconds"
307
+ )
308
+
309
+ try:
310
+ contents = await file.read()
311
+ df = pd.read_csv(io.StringIO(contents.decode('utf-8')))
312
+
313
+ if 'latitude' not in df.columns or 'longitude' not in df.columns:
314
+ raise HTTPException(
315
+ status_code=400,
316
+ detail="CSV must contain 'latitude' and 'longitude' columns"
317
+ )
318
+
319
+ import numpy as np
320
+ df = df[(np.abs(df['latitude']) <= 90) & (np.abs(df['longitude']) <= 180)]
321
+ if len(df) == 0:
322
+ raise HTTPException(status_code=400, detail="No valid coordinates in CSV (lat -90..90, lon -180..180)")
323
+
324
+ # Set defaults for optional columns
325
+ if 'height' not in df.columns:
326
+ df['height'] = 0.0
327
+ if 'basement' not in df.columns:
328
+ df['basement'] = 0.0
329
+
330
+ # Process rows with async throttling
331
+ results = []
332
+ for _, row in df.iterrows():
333
+ result = await process_single_row_async(row, use_predicted_height)
334
+ results.append(result)
335
+
336
+ results_df = pd.DataFrame(results)
337
+ output = io.StringIO()
338
+ results_df.to_csv(output, index=False)
339
+ output.seek(0)
340
+ return StreamingResponse(
341
+ io.BytesIO(output.getvalue().encode('utf-8')),
342
+ media_type="text/csv",
343
+ headers={
344
+ "Content-Disposition": (
345
+ "attachment; filename=vulnerability_results.csv; "
346
+ "filename*=UTF-8''vulnerability_results.csv"
347
+ )
348
+ }
349
+ )
350
+
351
+ except Exception as e:
352
+ raise HTTPException(status_code=500, detail=f"Batch processing failed: {str(e)}")
353
+
354
+
355
+ @app.post("/assess_batch_multihazard")
356
+ async def assess_batch_multihazard(file: UploadFile = File(...)) -> StreamingResponse:
357
+ if not gee_ready:
358
+ raise HTTPException(
359
+ status_code=503,
360
+ detail="GEE still initializing, try again in 10 seconds"
361
+ )
362
+
363
+ try:
364
+ contents = await file.read()
365
+ df = pd.read_csv(io.StringIO(contents.decode('utf-8')))
366
+
367
+ if 'latitude' not in df.columns or 'longitude' not in df.columns:
368
+ raise HTTPException(
369
+ status_code=400,
370
+ detail="CSV must contain 'latitude' and 'longitude' columns"
371
+ )
372
+
373
+ results = []
374
+ for _, row in df.iterrows():
375
+ result = await process_single_row_multihazard_async(row)
376
+ results.append(result)
377
+
378
+ results_df = pd.DataFrame(results)
379
+ output = io.StringIO()
380
+ results_df.to_csv(output, index=False)
381
+ output.seek(0)
382
+ return StreamingResponse(
383
+ io.BytesIO(output.getvalue().encode('utf-8')),
384
+ media_type="text/csv",
385
+ headers={
386
+ "Content-Disposition": (
387
+ "attachment; filename=multihazard_results.csv; "
388
+ "filename*=UTF-8''multihazard_results.csv"
389
+ )
390
+ }
391
+ )
392
+ except Exception as e:
393
+ raise HTTPException(status_code=500, detail=f"Batch multihazard failed: {str(e)}")
394
+
395
+
396
+ @app.post("/explain")
397
+ async def explain_assessment(data: SingleAssessment) -> Dict:
398
+ """Assess vulnerability with SHAP explanation"""
399
+ if not gee_ready:
400
+ raise HTTPException(
401
+ status_code=503,
402
+ detail="GEE still initializing, try again in 10 seconds"
403
+ )
404
+
405
+ loop = asyncio.get_event_loop()
406
+
407
+ try:
408
+ # Run terrain in background thread
409
+ terrain = await loop.run_in_executor(
410
+ None,
411
+ get_terrain_metrics,
412
+ data.latitude,
413
+ data.longitude
414
+ )
415
+
416
+ # Throttled water distance
417
+ water_dist = await throttled_distance_to_water(data.latitude, data.longitude)
418
+
419
+ result = calculate_vulnerability_index(
420
+ lat=data.latitude,
421
+ lon=data.longitude,
422
+ height=data.height,
423
+ basement=data.basement,
424
+ terrain_metrics=terrain,
425
+ water_distance=water_dist
426
+ )
427
+
428
+ # Generate explanation if explainer available
429
+ explanation = None
430
+ if explainer:
431
+ try:
432
+ explanation = explainer.explain(result['components'])
433
+ except Exception as e:
434
+ print(f"SHAP explanation failed: {e}")
435
+
436
+ return {
437
+ "status": "success",
438
+ "input": data.dict(),
439
+ "assessment": result,
440
+ "explanation": explanation
441
+ }
442
+
443
+ except Exception as e:
444
+ raise HTTPException(status_code=500, detail=f"Assessment failed: {e}")
445
+
446
+
447
+ async def process_single_row_multihazard_async(row):
448
+ """Process a single row with multi-hazard assessment."""
449
+ try:
450
+ from vulnerability import calculate_multi_hazard_vulnerability
451
+
452
+ lat = row['latitude']
453
+ lon = row['longitude']
454
+ height = row.get('height', 0.0)
455
+ basement = row.get('basement', 0.0)
456
+
457
+ loop = asyncio.get_event_loop()
458
+ terrain = await loop.run_in_executor(None, get_terrain_metrics, lat, lon)
459
+ water_dist = await throttled_distance_to_water(lat, lon)
460
+
461
+ result = calculate_multi_hazard_vulnerability(
462
+ lat=lat,
463
+ lon=lon,
464
+ height=height,
465
+ basement=basement,
466
+ terrain_metrics=terrain,
467
+ water_distance=water_dist
468
+ )
469
+
470
+ return {
471
+ 'latitude': lat,
472
+ 'longitude': lon,
473
+ 'height': height,
474
+ 'basement': basement,
475
+ 'vulnerability_index': result['vulnerability_index'],
476
+ 'ci_lower_95': result['confidence_interval']['lower_bound_95'],
477
+ 'ci_upper_95': result['confidence_interval']['upper_bound_95'],
478
+ 'risk_level': result['risk_level'],
479
+ 'confidence': result['uncertainty_analysis']['confidence'],
480
+ 'confidence_interpretation': result['uncertainty_analysis']['interpretation'],
481
+ 'elevation_m': result['elevation_m'],
482
+ 'tpi_m': result['relative_elevation_m'],
483
+ 'slope_degrees': result['slope_degrees'],
484
+ 'distance_to_water_m': result['distance_to_water_m'],
485
+ 'dominant_hazard': result['dominant_hazard'],
486
+ 'fluvial_risk': result['hazard_breakdown']['fluvial_riverine'],
487
+ 'coastal_risk': result['hazard_breakdown']['coastal_surge'],
488
+ 'pluvial_risk': result['hazard_breakdown']['pluvial_drainage'],
489
+ 'combined_risk': result['hazard_breakdown']['combined_index'],
490
+ 'quality_flags': ','.join(result['uncertainty_analysis']['data_quality_flags'])
491
+ if result['uncertainty_analysis']['data_quality_flags'] else ''
492
+ }
493
+
494
+ except Exception as e:
495
+ return {
496
+ 'latitude': row.get('latitude'),
497
+ 'longitude': row.get('longitude'),
498
+ 'error': str(e),
499
+ 'vulnerability_index': None
500
+ }
501
+
502
+
503
+ @app.post("/assess_multihazard")
504
+ async def assess_multihazard(data: SingleAssessment) -> Dict:
505
+ """Multi-hazard assessment (fluvial + coastal + pluvial)"""
506
+ if not gee_ready:
507
+ raise HTTPException(
508
+ status_code=503,
509
+ detail="GEE still initializing, try again in 10 seconds"
510
+ )
511
+
512
+ loop = asyncio.get_event_loop()
513
+
514
+ try:
515
+ from vulnerability import calculate_multi_hazard_vulnerability
516
+
517
+ # Run terrain in background thread
518
+ terrain = await loop.run_in_executor(
519
+ None,
520
+ get_terrain_metrics,
521
+ data.latitude,
522
+ data.longitude
523
+ )
524
+
525
+ # Throttled water distance
526
+ water_dist = await throttled_distance_to_water(data.latitude, data.longitude)
527
+
528
+ result = calculate_multi_hazard_vulnerability(
529
+ lat=data.latitude,
530
+ lon=data.longitude,
531
+ height=data.height,
532
+ basement=data.basement,
533
+ terrain_metrics=terrain,
534
+ water_distance=water_dist
535
+ )
536
+
537
+ return {
538
+ "status": "success",
539
+ "input": data.dict(),
540
+ "assessment": result
541
+ }
542
+
543
+ except Exception as e:
544
+ raise HTTPException(status_code=500, detail=f"Assessment failed: {e}")
545
+
546
+
547
+ class HeightRequest(BaseModel):
548
+ latitude: float
549
+ longitude: float
550
+
551
+ @field_validator("latitude")
552
+ @classmethod
553
+ def check_lat(cls, v: float) -> float:
554
+ if not -90 <= v <= 90:
555
+ raise ValueError("Latitude must be between -90 and 90")
556
+ return v
557
+
558
+ @field_validator("longitude")
559
+ @classmethod
560
+ def check_lon(cls, v: float) -> float:
561
+ if not -180 <= v <= 180:
562
+ raise ValueError("Longitude must be between -180 and 180")
563
+ return v
564
+
565
+
566
+ @app.post("/predict_height")
567
+ async def predict_height(data: HeightRequest) -> Dict:
568
+ if DISABLE_HEIGHT_PREDICTOR:
569
+ raise HTTPException(status_code=503,
570
+ detail="Height predictor disabled on this deployment.")
571
+ if not model_ready:
572
+ raise HTTPException(
573
+ status_code=503,
574
+ detail="Height predictor still loading, try again later."
575
+ )
576
+ try:
577
+ from height_predictor.inference import get_predictor
578
+ predictor = get_predictor()
579
+
580
+ loop = asyncio.get_event_loop()
581
+ result = await loop.run_in_executor(
582
+ None,
583
+ predictor.predict_from_coordinates,
584
+ data.latitude,
585
+ data.longitude,
586
+ )
587
+
588
+ return result
589
+
590
+ except Exception as e:
591
+ raise HTTPException(
592
+ status_code=500,
593
+ detail=f"Height prediction failed: {str(e)}",
594
+ )
595
+
596
+
597
+ # For local development
598
+ if __name__ == "__main__":
599
+ import uvicorn
600
+ import os
601
+ port = int(os.environ.get("PORT", 8000))
602
+ uvicorn.run(app, host="0.0.0.0", port=port)
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ earthengine-api==0.1.384
4
+ geopandas==1.0.1
5
+ pandas==2.2.0
6
+ numpy==1.26.4
7
+ shapely==2.0.6
8
+ pyproj==3.6.1
9
+ fiona==1.10.1
10
+ requests==2.31.0
11
+ jinja2==3.1.2
12
+ python-multipart==0.0.6
13
+ scikit-learn==1.7.2
14
+ shap==0.48.0
15
+ pydantic==2.12.4
16
+ pillow==12.0.0
17
+ onnxruntime
runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.11.9
spatial_queries.py ADDED
@@ -0,0 +1,754 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ee
2
+ import geopandas as gpd
3
+ from shapely.geometry import Point
4
+ import requests
5
+ import numpy as np
6
+ from functools import lru_cache
7
+ import warnings
8
+ import json
9
+ from pyproj import CRS, Transformer
10
+ import time
11
+ from datetime import datetime
12
+
13
+ # Initialize GEE
14
+ from gee_auth import initialize_gee
15
+
16
+ # Suppress shapely distance warnings
17
+ warnings.filterwarnings("ignore", category=RuntimeWarning, module="shapely.measurement")
18
+
19
+ # LAZY LOADING
20
+ _RIVERS = None
21
+ _LAKES = None
22
+
23
+ def get_rivers():
24
+ """Lazy load rivers dataset"""
25
+ global _RIVERS
26
+ if _RIVERS is None:
27
+ _RIVERS = gpd.read_file('data/natural_earth/ne_10m_rivers_lake_centerlines.shp')
28
+ _RIVERS = _RIVERS[_RIVERS.geometry.is_valid].copy()
29
+ print("✅ Rivers shapefile loaded")
30
+ return _RIVERS
31
+
32
+ def get_lakes():
33
+ """Lazy load lakes dataset"""
34
+ global _LAKES
35
+ if _LAKES is None:
36
+ _LAKES = gpd.read_file('data/natural_earth/ne_10m_lakes.shp')
37
+ _LAKES = _LAKES[_LAKES.geometry.is_valid].copy()
38
+ print("✅ Lakes shapefile loaded")
39
+ return _LAKES
40
+
41
+
42
+ def get_terrain_metrics(lat, lon, buffer_m=500, force_dem=None):
43
+ """
44
+ Extract DEM-based metrics with hierarchical fallback strategy.
45
+ """
46
+ initialize_gee()
47
+
48
+ if abs(lat) > 70:
49
+ buffer_m = 100
50
+
51
+ try:
52
+ if abs(lat) > 85:
53
+ print(f"Polar region {lat},{lon} - no terrain data")
54
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
55
+
56
+ point = ee.Geometry.Point([lon, lat])
57
+ region = point.buffer(buffer_m)
58
+
59
+ # Hierarchical DEM selection OR forced DEM for validation
60
+ if force_dem:
61
+ dem, dem_source = _get_forced_dem(lat, lon, force_dem)
62
+ if dem is None:
63
+ # Forced DEM not available at this location
64
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
65
+ else:
66
+ dem, dem_source = _select_best_dem(lat, lon)
67
+ if dem is None:
68
+ print(f"All DEM sources failed for {lat},{lon}")
69
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': None}
70
+
71
+ # Point elevation with smaller buffer
72
+ elevation_sample = dem.reduceRegion(
73
+ reducer=ee.Reducer.mean(),
74
+ geometry=point.buffer(15),
75
+ scale=30,
76
+ maxPixels=1e9,
77
+ bestEffort=True
78
+ )
79
+ elevation = elevation_sample.get('elevation').getInfo()
80
+
81
+ if elevation is None:
82
+ print(f"GEE elevation failed for {lat},{lon} using {dem_source}")
83
+ return {'elevation': None, 'slope': None, 'tpi': None, 'mean_elevation': None, 'dem_source': dem_source}
84
+
85
+ try:
86
+ mean_elevation_sample = dem.reduceRegion(
87
+ reducer=ee.Reducer.mean(),
88
+ geometry=region,
89
+ scale=30,
90
+ maxPixels=1e9,
91
+ bestEffort=True
92
+ )
93
+ mean_elevation = mean_elevation_sample.get('elevation').getInfo()
94
+ except Exception as me_err:
95
+ print(f"GEE mean elev failed for {lat},{lon}: {me_err}")
96
+ mean_elevation = None
97
+
98
+ # Slope
99
+ slope_img = ee.Terrain.slope(dem)
100
+ slope_mean = None
101
+ slope_max = None
102
+
103
+ def safe_reduce(reducer_type):
104
+ try:
105
+ reducer = ee.Reducer.mean() if reducer_type == 'mean' else ee.Reducer.max()
106
+ stats_dict = slope_img.reduceRegion(
107
+ reducer=reducer,
108
+ geometry=point.buffer(200),
109
+ scale=30,
110
+ maxPixels=1e9,
111
+ bestEffort=True
112
+ )
113
+ return stats_dict.get('slope').getInfo()
114
+ except Exception as err:
115
+ if "transform edge" not in str(err):
116
+ print(f"GEE slope {reducer_type} failed for {lat},{lon}: {err}")
117
+ return None
118
+
119
+ slope_mean = safe_reduce('mean')
120
+ slope_max = safe_reduce('max')
121
+ if slope_max is not None and slope_mean is not None:
122
+ if slope_max >= slope_mean * 1.8:
123
+ slope = slope_max
124
+ else:
125
+ slope = slope_mean
126
+ elif slope_mean is not None:
127
+ slope = slope_mean
128
+ elif slope_max is not None:
129
+ slope = slope_max
130
+ else:
131
+ slope = None
132
+
133
+ # TPI
134
+ tpi = None
135
+ if elevation is not None and mean_elevation is not None:
136
+ try:
137
+ tpi = float(elevation) - float(mean_elevation)
138
+ except (ValueError, TypeError):
139
+ tpi = None
140
+
141
+ return {
142
+ 'elevation': round(float(elevation), 2) if elevation is not None else None,
143
+ 'slope': round(float(slope), 2) if slope is not None else None,
144
+ 'tpi': round(float(tpi), 2) if tpi is not None else None,
145
+ 'mean_elevation': round(float(mean_elevation), 2) if mean_elevation is not None else None,
146
+ 'dem_source': dem_source
147
+ }
148
+
149
+ except Exception as e:
150
+ print(f"GEE error for {lat},{lon}: {e}")
151
+ return {
152
+ 'elevation': None,
153
+ 'slope': None,
154
+ 'tpi': None,
155
+ 'mean_elevation': None,
156
+ 'dem_source': None
157
+ }
158
+
159
+
160
+ def _select_best_dem(lat, lon):
161
+ """
162
+ Hierarchical DEM selection: prioritize highest-resolution DEM available.
163
+
164
+ """
165
+
166
+ point = ee.Geometry.Point([lon, lat])
167
+
168
+ # Regional high-resolution DEMs
169
+
170
+ # 1. USGS 3DEP 10m (USA)
171
+
172
+ if -130 < lon < -60 and 20 < lat < 55:
173
+ try:
174
+ usgs_10m = (
175
+ ee.ImageCollection("USGS/3DEP/10m_collection")
176
+ .filterBounds(point)
177
+ .mosaic()
178
+
179
+ )
180
+ # Dynamically detect elevation band
181
+ elev_band = usgs_10m.bandNames().getInfo()[0]
182
+ usgs_10m = usgs_10m.select(elev_band).rename("elevation")
183
+ usgs_10m = usgs_10m.reproject(crs="EPSG:4326", scale=10)
184
+
185
+ test = usgs_10m.reduceRegion(
186
+ ee.Reducer.first(),
187
+ point,
188
+ 10,
189
+ bestEffort=True
190
+ ).get("elevation").getInfo()
191
+
192
+ if test is not None:
193
+ print(f"Using USGS 3DEP 10m for {lat},{lon}")
194
+ return usgs_10m, "USGS_3DEP_10m_collection"
195
+
196
+ except Exception:
197
+ pass
198
+
199
+
200
+ # Netherlands AHN2/3/ (0.5 m – best national DEM globally)
201
+
202
+ if 50 < lat < 54 and 3 < lon < 8:
203
+
204
+ # Priority: AHN3 > AHN2
205
+
206
+ try:
207
+ # AHN3 (2014–2019)
208
+ ahn3 = ee.ImageCollection("AHN/AHN3").select("DTM").mosaic()
209
+ test = ahn3.reduceRegion(
210
+ ee.Reducer.first(), point, 1, bestEffort=True
211
+ ).get("DTM").getInfo()
212
+ if test is not None:
213
+ print(f"Using AHN3 0.5m DTM for {lat},{lon}")
214
+ return ahn3.rename("elevation"), "AHN3_0.5m"
215
+ except:
216
+ pass
217
+
218
+ try:
219
+ # AHN2 (2012)
220
+ ahn2 = ee.Image("AHN/AHN2_05M_INT").select("elevation")
221
+ test = ahn2.reduceRegion(
222
+ ee.Reducer.first(), point, 1, bestEffort=True
223
+ ).get("elevation").getInfo()
224
+ if test is not None:
225
+ print(f"Using AHN2 0.5m DTM for {lat},{lon}")
226
+ return ahn2, "AHN2_0.5m"
227
+ except:
228
+ pass
229
+
230
+
231
+ # 3. UK Environment Agency Composite DTM/DSM (1m)
232
+
233
+ if 49 < lat < 61 and -8 < lon < 3:
234
+ try:
235
+ ea = ee.Image("UK/EA/ENGLAND_1M_TERRAIN/2022")
236
+
237
+ # Identify available elevation band
238
+ bands = ea.bandNames().getInfo()
239
+ elev_candidates = [b for b in bands if b.lower() in ["dtm", "elevation", "b1"]]
240
+
241
+ if not elev_candidates:
242
+ raise Exception("No valid elevation band found")
243
+
244
+ elev_band = elev_candidates[0]
245
+
246
+ # Reproject to WGS84 before sampling
247
+ ea_reproj = ea.select(elev_band).reproject(
248
+ crs="EPSG:4326",
249
+ scale=2
250
+ )
251
+
252
+ test = ea_reproj.reduceRegion(
253
+ reducer=ee.Reducer.first(),
254
+ geometry=point,
255
+ scale=2,
256
+ bestEffort=True,
257
+ maxPixels=1e9
258
+ ).get(elev_band).getInfo()
259
+
260
+ if test is not None:
261
+ print(f"Using UK EA DTM 1m for {lat},{lon}")
262
+ return ea_reproj.rename("elevation"), "EA_UK_1m"
263
+
264
+ except Exception as e:
265
+ print(f"EA UK DEM failed for {lat},{lon}: {e}")
266
+ pass
267
+
268
+ # 4. Australia 5m DEM (LiDAR coastal & urban areas)
269
+
270
+ if -45 < lat < -10 and 110 < lon < 155:
271
+ try:
272
+
273
+ aus_col = ee.ImageCollection("AU/GA/AUSTRALIA_5M_DEM")
274
+
275
+ # Mosaic all tiles that intersect the point
276
+ aus = aus_col.filterBounds(point).mosaic()
277
+
278
+
279
+ elev_band = "elevation"
280
+
281
+ test = aus.select(elev_band).reduceRegion(
282
+ reducer=ee.Reducer.first(),
283
+ geometry=point,
284
+ scale=5,
285
+ bestEffort=True,
286
+ maxPixels=1e9
287
+ ).get(elev_band).getInfo()
288
+
289
+ if test is not None:
290
+ print(f"Using Australia 5m DEM for {lat},{lon}")
291
+ return aus.select(elev_band), "Australia_5m"
292
+
293
+ except Exception as e:
294
+ print(f"AU DEM failed for {lat},{lon}: {e}")
295
+ pass
296
+
297
+
298
+ # Global 30m DEMs
299
+
300
+ # 5. NASADEM
301
+
302
+ if -56 <= lat <= 60:
303
+ try:
304
+ nasadem = ee.Image("NASA/NASADEM_HGT/001").select("elevation")
305
+ test = nasadem.reduceRegion(
306
+ ee.Reducer.first(), point, 30, bestEffort=True
307
+ ).get("elevation").getInfo()
308
+
309
+ if test is not None:
310
+ print(f"Using NASADEM for {lat},{lon}")
311
+ return nasadem, "NASADEM"
312
+ except Exception:
313
+ pass
314
+
315
+ # 6. Copernicus GLO-30
316
+
317
+ try:
318
+ cop = ee.ImageCollection("COPERNICUS/DEM/GLO30").mosaic().select("DEM").rename("elevation")
319
+ test = cop.reduceRegion(
320
+ ee.Reducer.first(), point, 30, bestEffort=True
321
+ ).get("elevation").getInfo()
322
+
323
+ if test is not None:
324
+ print(f"Using Copernicus GLO-30 for {lat},{lon}")
325
+ return cop, "Copernicus_GLO30"
326
+ except Exception:
327
+ pass
328
+
329
+
330
+ # 7. ALOS World 3D-30m
331
+
332
+ if abs(lat) <= 82:
333
+ try:
334
+ alos = ee.ImageCollection("JAXA/ALOS/AW3D30/V4_1").mosaic().select("AVE").rename("elevation")
335
+ test = alos.reduceRegion(
336
+ ee.Reducer.first(), point, 30, bestEffort=True
337
+ ).get("elevation").getInfo()
338
+
339
+ if test is not None:
340
+ print(f"Using ALOS AW3D30 AVE for {lat},{lon}")
341
+ return alos, 'ALOS_AW3D30_AVE'
342
+ except Exception:
343
+ pass
344
+
345
+
346
+ # 8. SRTM fallback
347
+
348
+ if -56 <= lat <= 60:
349
+ try:
350
+ srtm = ee.Image("USGS/SRTMGL1_003").select("elevation")
351
+ test = srtm.reduceRegion(
352
+ ee.Reducer.first(), point, 30, bestEffort=True
353
+ ).get("elevation").getInfo()
354
+
355
+ if test is not None:
356
+ print(f"Using SRTM fallback for {lat},{lon}")
357
+ return srtm, "SRTM_v3"
358
+ except Exception:
359
+ pass
360
+
361
+ print(f"All DEM sources failed for {lat},{lon}")
362
+ return None, None
363
+
364
+ def _get_forced_dem(lat, lon, dem_name):
365
+ """
366
+ Force specific DEM retrieval for validation studies.
367
+ Returns None if DEM unavailable at location.
368
+
369
+ """
370
+ point = ee.Geometry.Point([lon, lat])
371
+
372
+ # Map DEM names to their retrieval logic
373
+ dem_map = {
374
+ 'ALOS_AW3D30': lambda: (
375
+ ee.ImageCollection("JAXA/ALOS/AW3D30/V4_1").mosaic().select("AVE").rename("elevation"),
376
+ 30
377
+ ),
378
+ 'Copernicus_GLO30': lambda: (
379
+ ee.ImageCollection("COPERNICUS/DEM/GLO30").mosaic().select("DEM").rename("elevation"),
380
+ 30
381
+ ),
382
+ 'NASADEM': lambda: (
383
+ ee.Image("NASA/NASADEM_HGT/001").select("elevation"),
384
+ 30
385
+ ),
386
+ 'SRTM_v3': lambda: (
387
+ ee.Image("USGS/SRTMGL1_003").select("elevation"),
388
+ 30
389
+ ),
390
+
391
+ 'AHN3_0.5m': lambda: (
392
+ ee.ImageCollection("AHN/AHN3").select("DTM").mosaic().rename("elevation"),
393
+ 1
394
+ ),
395
+ 'AHN2_0.5m': lambda: (
396
+ ee.Image("AHN/AHN2_05M_INT").select("elevation"),
397
+ 1
398
+ ),
399
+ 'EA_UK_1m': lambda: (
400
+ ee.Image("UK/EA/ENGLAND_1M_TERRAIN/2022").select("dtm").reproject(crs="EPSG:4326", scale=2).rename("elevation"),
401
+ 2
402
+ ),
403
+ 'Australia_5m': lambda: (
404
+ ee.ImageCollection("AU/GA/AUSTRALIA_5M_DEM").filterBounds(point).mosaic().select("elevation"),
405
+ 5
406
+ ),
407
+ 'USGS_3DEP_10m_collection': lambda: (
408
+ ee.ImageCollection("USGS/3DEP/10m_collection").filterBounds(point).mosaic().select("elevation"),
409
+ 10
410
+ )
411
+ }
412
+
413
+ if dem_name not in dem_map:
414
+ print(f"Unknown DEM name: {dem_name}")
415
+ return None, None
416
+
417
+ try:
418
+ dem, scale = dem_map[dem_name]()
419
+
420
+ # Test if data exists at this location
421
+ test = dem.reduceRegion(
422
+ ee.Reducer.first(),
423
+ point,
424
+ scale,
425
+ bestEffort=True
426
+ ).get("elevation").getInfo()
427
+
428
+ if test is not None:
429
+ print(f"Forced DEM {dem_name} available at {lat},{lon}")
430
+ return dem, dem_name
431
+ else:
432
+ print(f"Forced DEM {dem_name} has no data at {lat},{lon}")
433
+ return None, None
434
+
435
+ except Exception as e:
436
+ print(f"Failed to get forced DEM {dem_name} at {lat},{lon}: {e}")
437
+ return None, None
438
+
439
+ def is_significant_water_body(element):
440
+ """
441
+ Determine if water feature is significant for flood risk assessment
442
+ """
443
+ tags = element.get('tags', {})
444
+ name = tags.get('name', '')
445
+
446
+ # Filter by name - fountains
447
+ if name and ('fuente' in name.lower() or 'fountain' in name.lower() or
448
+ 'fonte' in name.lower()):
449
+ return False
450
+
451
+ # Filter by water type tag
452
+ water_type = tags.get('water', '')
453
+ if water_type in ['fountain', 'reflecting_pool', 'pond', 'ornamental']:
454
+ return False
455
+
456
+ # Filter by amenity tag
457
+ if tags.get('amenity') == 'fountain':
458
+ return False
459
+
460
+ # Check if it's a waterway (rivers/streams/canals are significant)
461
+ if tags.get('waterway') in ['river', 'stream', 'canal', 'drain']:
462
+ return True
463
+
464
+ # Calculate approximate area for unnamed water bodies
465
+ if tags.get('natural') == 'water' and 'geometry' in element:
466
+ coords = element.get('geometry', [])
467
+
468
+ if len(coords) >= 3:
469
+ lons = [c['lon'] for c in coords]
470
+ lats = [c['lat'] for c in coords]
471
+
472
+ width = (max(lons) - min(lons)) * 111320
473
+ height = (max(lats) - min(lats)) * 111320
474
+ approx_area = width * height
475
+
476
+ if approx_area < 500:
477
+ return False
478
+
479
+ if len(coords) < 10 and approx_area < 2000:
480
+ return False
481
+
482
+ # Natural water bodies with names (excluding fountains)
483
+ if tags.get('natural') == 'water' and name:
484
+ return True
485
+
486
+ # Large unnamed water bodies
487
+ if tags.get('natural') == 'water' and not name:
488
+ coords = element.get('geometry', [])
489
+ if len(coords) > 50:
490
+ return True
491
+
492
+ return False
493
+
494
+
495
+ def distance_to_water_osm(lat, lon, radius_m=5000, timeout=20, retry_count=2):
496
+ """
497
+ Query OpenStreetMap for nearby SIGNIFICANT water bodies with retry logic
498
+ """
499
+ overpass_url = "http://overpass-api.de/api/interpreter"
500
+
501
+ query = f"""
502
+ [out:json][timeout:{timeout}];
503
+ (
504
+ way["natural"="water"](around:{radius_m},{lat},{lon});
505
+ way["waterway"="river"](around:{radius_m},{lat},{lon});
506
+ way["waterway"="canal"](around:{radius_m},{lat},{lon});
507
+ way["waterway"="stream"](around:{radius_m},{lat},{lon});
508
+ relation["natural"="water"](around:{radius_m},{lat},{lon});
509
+ way["natural"="bay"](around:{radius_m},{lat},{lon});
510
+ );
511
+ out geom;
512
+ """
513
+
514
+ for attempt in range(retry_count):
515
+ try:
516
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
517
+ print(f"Invalid coords for OSM: {lat},{lon}")
518
+ return None
519
+ response = requests.post(overpass_url, data={'data': query}, timeout=timeout)
520
+
521
+ if response.status_code == 429:
522
+ print(f"OSM rate limited for {lat},{lon} - waiting {2 ** attempt}s")
523
+ time.sleep(2 ** attempt)
524
+ continue
525
+
526
+ if response.status_code == 400:
527
+ print(f"OSM 400 for {lat},{lon} - bad query")
528
+ return None
529
+
530
+ if response.status_code != 200:
531
+ print(f"OSM HTTP {response.status_code} for {lat},{lon}")
532
+ if attempt < retry_count - 1:
533
+ time.sleep(1)
534
+ continue
535
+ return None
536
+
537
+ if not response.text.strip():
538
+ print(f"OSM empty response for {lat},{lon}")
539
+ return None
540
+
541
+ try:
542
+ data = response.json()
543
+ except (json.JSONDecodeError, ValueError) as je:
544
+ print(f"OSM JSON decode failed for {lat},{lon}: {je}")
545
+ return None
546
+
547
+ if not data.get('elements'):
548
+ print(f"OSM no elements found for {lat},{lon}")
549
+ return None
550
+
551
+ point = Point(lon, lat)
552
+ min_distance = float('inf')
553
+
554
+ significant_features = [e for e in data['elements'] if is_significant_water_body(e)]
555
+
556
+ if not significant_features and radius_m < 12500:
557
+ print(f"Retrying {lat},{lon} with extended radius...")
558
+ return distance_to_water_osm(lat, lon, radius_m=10000, timeout=timeout, retry_count=1)
559
+
560
+ if not significant_features:
561
+ print(f"OSM only ornamental features for {lat},{lon}")
562
+ return None
563
+
564
+ from shapely.geometry import LineString, Polygon
565
+
566
+ for element in significant_features:
567
+ if 'geometry' in element and len(element['geometry']) >= 2:
568
+ coords = [(node['lon'], node['lat']) for node in element['geometry']]
569
+
570
+ if element.get('tags', {}).get('waterway'):
571
+ try:
572
+ water_geom = LineString(coords)
573
+ except Exception:
574
+ continue
575
+ else:
576
+ try:
577
+ water_geom = Polygon(coords)
578
+ except:
579
+ try:
580
+ water_geom = LineString(coords)
581
+ except:
582
+ continue
583
+
584
+ if not water_geom.is_valid:
585
+ continue
586
+
587
+ distance = point.distance(water_geom) * 111320
588
+ if not np.isnan(distance):
589
+ min_distance = min(min_distance, distance)
590
+
591
+ result = min_distance if min_distance != float('inf') else None
592
+ if result is not None:
593
+ print(f"OSM success for {lat},{lon}: {result:.1f}m")
594
+ return result
595
+
596
+ except requests.exceptions.Timeout:
597
+ print(f"OSM timeout for {lat},{lon} (attempt {attempt + 1}/{retry_count})")
598
+ if attempt < retry_count - 1:
599
+ time.sleep(1)
600
+ continue
601
+ return None
602
+ except Exception as e:
603
+ print(f"OSM exception for {lat},{lon}: {e}")
604
+ if attempt < retry_count - 1:
605
+ time.sleep(1)
606
+ continue
607
+ return None
608
+
609
+ return None
610
+
611
+
612
+ def distance_to_water_static(lat, lon):
613
+ """
614
+ Fallback: calculate distance to Natural Earth water bodies
615
+ """
616
+ point = Point(lon, lat)
617
+
618
+ utm_zone = int((lon + 180) / 6) + 1
619
+ hemisphere = 'north' if lat >= 0 else 'south'
620
+ utm_crs = CRS.from_string(f"+proj=utm +zone={utm_zone} +{hemisphere} +datum=WGS84")
621
+
622
+ transformer = Transformer.from_crs("EPSG:4326", utm_crs, always_xy=True)
623
+ point_utm_coords = transformer.transform(lon, lat)
624
+ point_utm = Point(point_utm_coords)
625
+
626
+ try:
627
+ # Use lazy-loaded datasets
628
+ rivers_utm = get_rivers().to_crs(utm_crs)
629
+ lakes_utm = get_lakes().to_crs(utm_crs)
630
+
631
+ river_distances = rivers_utm.geometry.distance(point_utm)
632
+ river_distances = river_distances[river_distances.notna()]
633
+ min_river_dist = river_distances.min() if len(river_distances) > 0 else np.inf
634
+
635
+ lake_distances = lakes_utm.geometry.distance(point_utm)
636
+ lake_distances = lake_distances[lake_distances.notna()]
637
+ min_lake_dist = lake_distances.min() if len(lake_distances) > 0 else np.inf
638
+
639
+ min_dist = min(min_river_dist, min_lake_dist)
640
+ result = min_dist if min_dist != np.inf else None
641
+
642
+ if result is not None:
643
+ print(f"Static fallback for {lat},{lon}: {result:.1f}m")
644
+ else:
645
+ print(f"Static fallback failed for {lat},{lon}")
646
+
647
+ return result
648
+ except Exception as p_err:
649
+ print(f"Static distance error for {lat},{lon}: {p_err}")
650
+ return None
651
+
652
+ def check_coastal(lat, lon, timeout=15):
653
+ """
654
+ Adaptive coastal detection: expands search radius until coastline is found.
655
+ """
656
+ overpass_url = "http://overpass-api.de/api/interpreter"
657
+ point = Point(lon, lat)
658
+
659
+ # Sweep radii from 1 km to 5 km
660
+ radii = [1000, 2000, 5000]
661
+ print(f"[Coastal] Starting coastal search for {lat},{lon} ...")
662
+ for r in radii:
663
+ query = f"""
664
+ [out:json][timeout:{timeout}];
665
+ (
666
+ way["natural"="coastline"](around:{r},{lat},{lon});
667
+ );
668
+ out geom;
669
+ """
670
+
671
+ try:
672
+ response = requests.post(overpass_url, data={'data': query}, timeout=timeout)
673
+
674
+ if not response.text.strip():
675
+ continue
676
+
677
+ try:
678
+ data = response.json()
679
+ except:
680
+ continue
681
+
682
+ if not data.get('elements'):
683
+ print(f"[Coastal] No coastline found at {r} m")
684
+ continue
685
+
686
+ min_distance = float('inf')
687
+ from shapely.geometry import LineString
688
+
689
+ for element in data['elements']:
690
+ if 'geometry' in element and len(element['geometry']) >= 2:
691
+ coords = [(node['lon'], node['lat']) for node in element['geometry']]
692
+ coastline = LineString(coords)
693
+ distance = point.distance(coastline) * 111320
694
+ min_distance = min(min_distance, distance)
695
+
696
+ if min_distance != float('inf'):
697
+ print(f"Coastal detected for {lat},{lon}: {min_distance:.1f}m (radius={r})")
698
+ return True, min_distance
699
+
700
+ except Exception as e:
701
+ print(f"[Coastal] Error at radius {r}: {e}")
702
+ continue
703
+
704
+ # If nothing is found
705
+ print(f"[Coastal] No coastline detected for {lat},{lon}. Continuing with OSM water search.")
706
+ return False, None
707
+
708
+
709
+ @lru_cache(maxsize=1000)
710
+ def distance_to_water(lat, lon):
711
+ """
712
+ Combined water distance with caching for batch efficiency.
713
+ Uses OSM first, then Natural Earth fallback.
714
+ """
715
+ lat, lon = round(float(lat), 6), round(float(lon), 6)
716
+ print(f"--- Water distance query for {lat},{lon} ---")
717
+
718
+ # 1. Check coastal proximity
719
+ try:
720
+ is_coastal, coast_distance = check_coastal(lat, lon)
721
+ if is_coastal and coast_distance is not None:
722
+ print(f"Coastal detected for {lat},{lon}: {coast_distance:.1f} m")
723
+ return coast_distance
724
+ except Exception as e:
725
+ print(f"Coastal check failed for {lat},{lon}: {e}")
726
+
727
+ # 2. Try OSM query with retries
728
+ for radius in [3000, 5000, 8000]:
729
+ for attempt in range(3):
730
+ try:
731
+ print(f"OSM attempt {attempt + 1}/3 at radius {radius} m for {lat},{lon}")
732
+ d = distance_to_water_osm(lat, lon, radius_m=radius)
733
+ if d is not None:
734
+ print(f"OSM success for {lat},{lon}: {d:.1f} m (radius={radius})")
735
+ return d
736
+ except Exception as e:
737
+ print(f"OSM exception on attempt {attempt + 1} for {lat},{lon}: {e}")
738
+ time.sleep(1.5)
739
+ time.sleep(1.5)
740
+
741
+ # 3. Static fallback
742
+ try:
743
+ d_static = distance_to_water_static(lat, lon)
744
+ if d_static is not None:
745
+ corrected = d_static * 0.7
746
+ print(f"Static fallback for {lat},{lon}: raw={d_static:.1f} m, corrected={corrected:.1f} m")
747
+ return corrected
748
+ else:
749
+ print(f"Static fallback failed for {lat},{lon}")
750
+ except Exception as e:
751
+ print(f"Static distance error for {lat},{lon}: {e}")
752
+
753
+ print(f"All water distance queries failed for {lat},{lon}")
754
+ return None
vulnerability.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # vulnerability.py
2
+
3
+ import numpy as np
4
+
5
+ def normalize_component(value, max_value, inverse=False):
6
+ """
7
+ Normalize to 0-1 range
8
+
9
+ """
10
+ if value is None:
11
+ return 0.5
12
+
13
+ if inverse:
14
+ normalized = min(1.0, abs(value) / max_value)
15
+ else:
16
+ normalized = max(0.0, 1.0 - (abs(value) / max_value))
17
+
18
+ return normalized
19
+
20
+ def assess_flood_context(elevation, tpi, water_distance):
21
+ # Context 1: Coastal (<10m)
22
+ if elevation < 10:
23
+ if water_distance is not None and water_distance < 500:
24
+ return 'very_high', 1.0
25
+ elif water_distance is not None and water_distance < 2000:
26
+ return 'very_high' if tpi < -3 else 'very high', 1.0 if tpi < -3 else 0.98
27
+ elif water_distance is not None and water_distance < 5000:
28
+ return 'high' if tpi < -3 else 'moderate', 0.9 if tpi < -3 else 0.75
29
+ else:
30
+ return 'moderate', 0.7 if tpi < -5 else 0.6
31
+
32
+ # Context 2: High plateau (>600m)
33
+ elif elevation > 600:
34
+ if tpi < -15 and water_distance is not None and water_distance < 100:
35
+ return 'moderate', 0.65
36
+ elif tpi < -10:
37
+ return 'low', 0.55
38
+ else:
39
+ return 'low', 0.50
40
+
41
+ # Context 3: Mountain (300–600m)
42
+ elif elevation > 300:
43
+ if water_distance is not None and water_distance < 200 and tpi < -10:
44
+ return 'moderate', 0.75
45
+ elif water_distance is not None and water_distance < 500:
46
+ return 'low', 0.65
47
+ else:
48
+ return 'low', 0.55
49
+
50
+ # Context 4: River valley (100–300m)
51
+ elif 100 < elevation < 300:
52
+ if water_distance is not None and water_distance < 300 and tpi < -5:
53
+ return 'high', 1.0
54
+ elif water_distance is not None and water_distance < 500:
55
+ return 'moderate', 0.85
56
+ else:
57
+ return 'moderate', 0.7
58
+
59
+ # Context 5: Low inland (10–100m)
60
+ else:
61
+ if water_distance is None:
62
+ return 'moderate', 0.7
63
+ elif water_distance < 200:
64
+ if tpi < -8:
65
+ return 'very_high', 1.0
66
+ elif tpi < -5:
67
+ return 'high', 0.95
68
+ else:
69
+ return 'high', 0.85
70
+ elif water_distance < 500:
71
+ return 'high' if tpi < -5 else 'moderate', 0.85 if tpi < -5 else 0.75
72
+ elif water_distance < 1000:
73
+ return 'moderate', 0.70 if tpi < -5 else 0.65
74
+ else:
75
+ if tpi < -8:
76
+ return 'moderate', 0.65
77
+ elif tpi < -5:
78
+ return 'low', 0.60
79
+ else:
80
+ return 'low', 0.55
81
+
82
+ def calculate_vulnerability_index(lat, lon, height, basement, terrain_metrics, water_distance):
83
+ """
84
+ Calculate flood vulnerability index with basement consideration
85
+
86
+ """
87
+
88
+ elevation = terrain_metrics.get('elevation') or 0
89
+ tpi = terrain_metrics.get('tpi') or 0
90
+ slope = terrain_metrics.get('slope') or 0
91
+
92
+ # GET FLOOD CONTEXT
93
+ try:
94
+ context_risk_level, context_factor = assess_flood_context(elevation, tpi, water_distance)
95
+ except (TypeError, ValueError) as te:
96
+ print(f"Context failed for {lat},{lon}: {te} - default moderate")
97
+ context_risk_level, context_factor = 'moderate', 0.8
98
+
99
+ # Apply elevation penalty for high-altitude locations
100
+ if elevation > 500:
101
+ elevation_factor = max(0.3, 1.0 - (elevation - 500) / 1000)
102
+ else:
103
+ elevation_factor = 1.0
104
+
105
+ # Component 1: Proximity (with elevation adjustment)
106
+ if water_distance is None:
107
+ proximity_score = 0.5
108
+ elif water_distance < 100:
109
+ proximity_score = 1.0 * elevation_factor
110
+ elif water_distance < 500:
111
+ proximity_score = (0.9 - ((water_distance - 100) / 400) * 0.5) * elevation_factor
112
+ elif water_distance < 2000:
113
+ proximity_score = (0.4 - ((water_distance - 500) / 1500) * 0.3) * elevation_factor
114
+ elif water_distance < 5000:
115
+ proximity_score = max(0.0, 0.1 - ((water_distance - 2000) / 3000) * 0.1) * elevation_factor
116
+ else:
117
+ proximity_score = 0.0
118
+
119
+ # Component 2: TPI (Topographic Position Index)
120
+ if tpi is not None:
121
+ if tpi < -5:
122
+ tpi_score = min(1.0, 0.7 + abs(tpi + 5) / 30)
123
+ elif tpi > 5:
124
+ tpi_score = max(0.0, 0.3 - (tpi - 5) / 50)
125
+ else:
126
+ tpi_score = 0.5 - (tpi / 20)
127
+ else:
128
+ tpi_score = 0.5
129
+
130
+ tpi_score = max(0.0, min(1.0, tpi_score))
131
+
132
+ if elevation > 500:
133
+ tpi_score = tpi_score * elevation_factor
134
+
135
+ # Component 3: Slope
136
+ if slope < 0.5:
137
+ slope_score = 0.9
138
+ elif slope < 2:
139
+ slope_score = 0.8 - ((slope - 0.5) / 1.5) * 0.3
140
+ elif slope < 6:
141
+ slope_score = 0.5 - ((slope - 2) / 4) * 0.3
142
+ else:
143
+ slope_score = max(0.05, 0.2 - (slope - 6) / 20)
144
+
145
+
146
+ # Component 4: Building protection factor
147
+ net_protection = height + abs(basement)
148
+
149
+ # Height protection calculation (without basement penalty)
150
+ if net_protection <= 0:
151
+ height_score = 0.9
152
+ elif net_protection < 3:
153
+ height_score = 0.8 - (net_protection / 3) * 0.3
154
+ elif net_protection < 8:
155
+ height_score = 0.5 - ((net_protection - 3) / 5) * 0.3
156
+ else:
157
+ height_score = max(0.1, 0.2 - ((net_protection - 8) / 15) * 0.15)
158
+
159
+ height_score = max(0.0, min(1.0, height_score))
160
+
161
+ # Increase weight for building characteristics when basement present
162
+ if basement < 0:
163
+ weights = {
164
+ 'proximity': 0.25,
165
+ 'tpi': 0.30,
166
+ 'slope': 0.15,
167
+ 'height': 0.30
168
+ }
169
+ else:
170
+ weights = {
171
+ 'proximity': 0.30,
172
+ 'tpi': 0.35,
173
+ 'slope': 0.20,
174
+ 'height': 0.15
175
+ }
176
+
177
+ # Base vulnerability
178
+ base_vulnerability = (
179
+ weights['proximity'] * proximity_score +
180
+ weights['tpi'] * tpi_score +
181
+ weights['slope'] * slope_score +
182
+ weights['height'] * height_score
183
+ )
184
+
185
+ # Basement as multiplier
186
+ if basement < 0:
187
+ basement_multiplier = 1.0 + (abs(basement) * 0.15)
188
+ base_vulnerability = min(1.0, base_vulnerability * basement_multiplier)
189
+
190
+ # Apply context adjustment
191
+ vulnerability_index = base_vulnerability * context_factor
192
+
193
+ # Risk level based on final vulnerability_index with threshold mapping
194
+ if vulnerability_index >= 0.80:
195
+ final_risk = 'very_high'
196
+ elif vulnerability_index >= 0.65:
197
+ final_risk = 'high'
198
+ elif vulnerability_index >= 0.40:
199
+ final_risk = 'moderate'
200
+ elif vulnerability_index >= 0.20:
201
+ final_risk = 'low'
202
+ else:
203
+ final_risk = 'very_low'
204
+
205
+ # Keep context-based label if more severe
206
+ risk_levels_order = ['very_low', 'low', 'moderate', 'high', 'very_high']
207
+ context_severity = risk_levels_order.index(context_risk_level) if context_risk_level in risk_levels_order else 2
208
+ final_severity = risk_levels_order.index(final_risk)
209
+
210
+ risk_level = risk_levels_order[max(context_severity, final_severity)]
211
+
212
+
213
+
214
+ # Track component scores for SHAP
215
+ components = {
216
+ 'proximity_score': proximity_score,
217
+ 'tpi_score': tpi_score,
218
+ 'slope_score': slope_score,
219
+ 'height_score': height_score,
220
+ 'elevation': elevation
221
+ }
222
+
223
+ # Calculate uncertainty
224
+ uncertainty_analysis = calculate_uncertainty(
225
+ terrain_metrics,
226
+ water_distance,
227
+ context_factor,
228
+ lat,
229
+ lon
230
+ )
231
+
232
+
233
+ # Calculate confidence interval
234
+ confidence_interval = calculate_confidence_interval(
235
+ vulnerability_index,
236
+ uncertainty_analysis['uncertainty']
237
+ )
238
+
239
+ return {
240
+ 'vulnerability_index': round(vulnerability_index, 3),
241
+ 'confidence_interval': confidence_interval,
242
+ 'risk_level': risk_level,
243
+ 'distance_to_water_m': round(water_distance, 1) if water_distance else None,
244
+ 'elevation_m': elevation,
245
+ 'relative_elevation_m': round(tpi, 2) if tpi is not None else None,
246
+ 'slope_degrees': round(slope, 2) if slope is not None else None,
247
+ 'uncertainty_analysis': uncertainty_analysis,
248
+ 'components': components
249
+ }
250
+
251
+
252
+ def calculate_uncertainty(terrain_metrics, water_distance, context_factor, lat, lon):
253
+ """
254
+ Physically-based uncertainty quantification - FIXED scaling
255
+ """
256
+ uncertainties = {}
257
+
258
+ # 1. ELEVATION UNCERTAINTY
259
+ elevation = terrain_metrics.get('elevation')
260
+ slope = terrain_metrics.get('slope') or 0
261
+
262
+ if elevation is None:
263
+ uncertainties['elevation'] = 0.15
264
+ else:
265
+ # Base DEM error in meters
266
+ if abs(lat) < 60:
267
+ base_error_m = 2.5
268
+ else:
269
+ base_error_m = 4.0
270
+
271
+ # Slope increases error
272
+ if slope > 15:
273
+ slope_multiplier = 1 + (slope - 15) / 30
274
+ base_error_m *= slope_multiplier
275
+
276
+ # Convert to normalized uncertainty
277
+ if elevation < 10:
278
+ uncertainties['elevation'] = 0.08 # coastal - elevation matters a lot
279
+ elif elevation < 100:
280
+ uncertainties['elevation'] = 0.06 # low inland
281
+ else:
282
+ uncertainties['elevation'] = 0.03 # elevated - less critical
283
+
284
+ # 2. TPI UNCERTAINTY
285
+ tpi = terrain_metrics.get('tpi')
286
+
287
+ if tpi is None:
288
+ uncertainties['tpi'] = 0.12
289
+ else:
290
+ # TPI uncertainty affects the depression detection
291
+ if abs(tpi) < 2:
292
+ uncertainties['tpi'] = 0.10 # near-flat, hard to classify
293
+ elif abs(tpi) < 5:
294
+ uncertainties['tpi'] = 0.06
295
+ else:
296
+ uncertainties['tpi'] = 0.04 # clear depression/ridge
297
+
298
+ # 3. SLOPE UNCERTAINTY
299
+ if slope is None:
300
+ uncertainties['slope'] = 0.10
301
+ else:
302
+ if slope < 2:
303
+ uncertainties['slope'] = 0.08 # very flat = uncertain
304
+ elif slope < 10:
305
+ uncertainties['slope'] = 0.04
306
+ else:
307
+ uncertainties['slope'] = 0.03 # steep = clear signal
308
+
309
+ # 4. WATER DISTANCE UNCERTAINTY
310
+ if water_distance is None:
311
+ uncertainties['water_proximity'] = 0.20
312
+ elif water_distance < 50:
313
+ uncertainties['water_proximity'] = 0.03
314
+ elif water_distance < 500:
315
+ uncertainties['water_proximity'] = 0.06
316
+ elif water_distance < 2000:
317
+ uncertainties['water_proximity'] = 0.10
318
+ else:
319
+ uncertainties['water_proximity'] = 0.15
320
+
321
+ # 5. CONTEXT UNCERTAINTY
322
+ if context_factor < 0.7:
323
+ uncertainties['context'] = 0.04
324
+ elif context_factor > 0.95:
325
+ uncertainties['context'] = 0.06
326
+ else:
327
+ uncertainties['context'] = 0.03
328
+
329
+ # 6. MODEL STRUCTURAL UNCERTAINTY
330
+ uncertainties['model'] = 0.08
331
+
332
+ # Weight by component importance in vulnerability calculation
333
+ weights = {
334
+ 'elevation': 0.20,
335
+ 'tpi': 0.30,
336
+ 'slope': 0.15,
337
+ 'water_proximity': 0.25,
338
+ 'context': 0.05,
339
+ 'model': 0.05
340
+ }
341
+
342
+ # Weighted root-sum-of-squares
343
+ weighted_variance = sum(weights[k] * (v ** 2) for k, v in uncertainties.items())
344
+ total_uncertainty = np.sqrt(weighted_variance)
345
+
346
+ # Additional damping factor
347
+ total_uncertainty *= 0.7 # empirical adjustment
348
+
349
+ confidence = max(0.0, min(1.0, 1.0 - total_uncertainty))
350
+
351
+ # Get dominant error sources
352
+ sorted_uncertainties = sorted(uncertainties.items(), key=lambda x: x[1], reverse=True)
353
+ dominant_sources = sorted_uncertainties[:3]
354
+
355
+ return {
356
+ 'confidence': round(confidence, 3),
357
+ 'uncertainty': round(total_uncertainty, 3),
358
+ 'components': {k: round(v, 3) for k, v in uncertainties.items()},
359
+ 'interpretation': interpret_confidence(confidence),
360
+ 'data_quality_flags': get_quality_flags(terrain_metrics, water_distance),
361
+ 'dominant_error_sources': dominant_sources
362
+ }
363
+ def get_quality_flags(terrain_metrics, water_distance):
364
+ """
365
+ Identify specific data quality issues
366
+ """
367
+ flags = []
368
+
369
+ if terrain_metrics.get('elevation') is None:
370
+ flags.append('missing_elevation')
371
+
372
+ if terrain_metrics.get('tpi') is None:
373
+ flags.append('missing_tpi')
374
+
375
+ if terrain_metrics.get('slope') is None:
376
+ flags.append('missing_slope')
377
+
378
+ if water_distance is None:
379
+ flags.append('water_distance_unknown')
380
+ elif water_distance > 5000:
381
+ flags.append('far_from_water_search_limited')
382
+
383
+ elevation = terrain_metrics.get('elevation') or 0
384
+ slope = terrain_metrics.get('slope') or 0
385
+
386
+ if slope > 20:
387
+ flags.append('steep_terrain_dem_error_high')
388
+
389
+ if elevation < 1 and water_distance is not None and water_distance < 100:
390
+ flags.append('coastal_surge_risk_not_modeled')
391
+
392
+ return flags
393
+ def interpret_confidence(confidence):
394
+ """
395
+ Realistic confidence interpretation
396
+ """
397
+ if confidence >= 0.85:
398
+ return "High confidence - complete terrain data with low uncertainty"
399
+ elif confidence >= 0.75:
400
+ return "Good confidence - reliable data sources available"
401
+ elif confidence >= 0.65:
402
+ return "Moderate confidence - some data limitations present"
403
+ elif confidence >= 0.50:
404
+ return "Fair confidence - significant data gaps or measurement uncertainty"
405
+ else:
406
+ return "Low confidence - substantial missing data, use with caution"
407
+
408
+ def calculate_confidence_interval(vulnerability_index, uncertainty):
409
+ """
410
+ Calculate 95% confidence interval with proper bounds
411
+ """
412
+
413
+ margin = 1.96 * uncertainty
414
+
415
+ # Clip to valid 0-1 range
416
+ lower = max(0.0, vulnerability_index - margin)
417
+ upper = min(1.0, vulnerability_index + margin)
418
+
419
+ return {
420
+ 'point_estimate': round(vulnerability_index, 3),
421
+ 'lower_bound_95': round(lower, 3),
422
+ 'upper_bound_95': round(upper, 3),
423
+ 'margin_of_error': round(margin, 3)
424
+ }
425
+
426
+ def calculate_multi_hazard_vulnerability(lat, lon, height, basement, terrain_metrics, water_distance):
427
+ """
428
+ Multi-hazard assessment
429
+ """
430
+ # Base assessment
431
+ base_result = calculate_vulnerability_index(
432
+ lat, lon, height, basement, terrain_metrics, water_distance
433
+ )
434
+
435
+ elevation = terrain_metrics.get('elevation') or 0
436
+
437
+ # Coastal surge risk
438
+ from spatial_queries import check_coastal
439
+ is_coastal, coast_distance = check_coastal(lat, lon)
440
+ if is_coastal and coast_distance < 5000:
441
+ if elevation < 2:
442
+ coastal_risk = 0.99
443
+ elif elevation < 10:
444
+ coastal_risk = max(0.05, 0.99 - ((elevation - 2) / 8) * 0.95)
445
+ else:
446
+ coastal_risk = 0.15 # Residual surge potential
447
+ else:
448
+ coastal_risk = 0.0
449
+
450
+ # Pluvial risk
451
+ tpi = terrain_metrics.get('tpi') or 0
452
+ slope = terrain_metrics.get('slope') or 0
453
+
454
+ if tpi < -5:
455
+ topo_factor = 1.0
456
+ elif tpi < 0:
457
+ topo_factor = 0.5 + abs(tpi) / 5 * 0.5
458
+ else:
459
+ topo_factor = 0.5
460
+
461
+ if slope < 1:
462
+ slope_factor = 0.85
463
+ elif slope < 3:
464
+ slope_factor = 0.65
465
+ else:
466
+ slope_factor = 0.3
467
+
468
+ # Elevation decay for pluvial
469
+ if elevation > 800:
470
+ elevation_decay = max(0.1, 1.0 - (elevation - 800) / 1000)
471
+ elif elevation > 400:
472
+ elevation_decay = max(0.5, 1.0 - (elevation - 400) / 800)
473
+ else:
474
+ elevation_decay = 1.0
475
+
476
+ pluvial_risk = (topo_factor * 0.6 + slope_factor * 0.4) * elevation_decay
477
+
478
+ # Combined hazard with adaptive weights
479
+ if elevation < 10: # Coastal zone
480
+ weights = {'fluvial': 0.3, 'coastal': 0.5, 'pluvial': 0.2}
481
+ elif elevation < 100: # Low inland
482
+ weights = {'fluvial': 0.5, 'coastal': 0.1, 'pluvial': 0.4}
483
+ else: # Elevated
484
+ weights = {'fluvial': 0.6, 'coastal': 0.0, 'pluvial': 0.4}
485
+
486
+ combined = (base_result['vulnerability_index'] * weights['fluvial'] +
487
+ coastal_risk * weights['coastal'] +
488
+ pluvial_risk * weights['pluvial'])
489
+
490
+ # Identify dominant hazard
491
+ hazards = {
492
+ 'fluvial_riverine': base_result['vulnerability_index'],
493
+ 'coastal_surge': coastal_risk,
494
+ 'pluvial_drainage': pluvial_risk
495
+ }
496
+ dominant = max(hazards, key=hazards.get)
497
+
498
+ return {
499
+ **base_result,
500
+ 'hazard_breakdown': {
501
+ 'fluvial_riverine': round(base_result['vulnerability_index'], 3),
502
+ 'coastal_surge': round(coastal_risk, 3),
503
+ 'pluvial_drainage': round(pluvial_risk, 3),
504
+ 'combined_index': round(combined, 3)
505
+ },
506
+ 'dominant_hazard': dominant
507
+ }