adema5051 commited on
Commit
f023554
Β·
verified Β·
1 Parent(s): 34c2d84

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +442 -602
main.py CHANGED
@@ -1,602 +1,442 @@
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)
 
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
+
12
+ from spatial_queries import get_terrain_metrics, distance_to_water
13
+ from vulnerability import calculate_vulnerability_index
14
+ from gee_auth import initialize_gee
15
+ from height_predictor.inference import get_predictor
16
+
17
+ # SHAP Explainer Initialization
18
+ try:
19
+ from explainability import VulnerabilityExplainer
20
+ explainer = VulnerabilityExplainer() # Automatically loads rf_explainer.pkl if present
21
+ print("βœ… SHAP model initialized successfully.")
22
+ except Exception as e:
23
+ print(f"⚠️ SHAP explainer not available: {e}")
24
+ explainer = None
25
+
26
+ # Initialize GEE once at startup
27
+ try:
28
+ initialize_gee()
29
+ print("βœ… GEE initialized once at startup.")
30
+ except Exception as e:
31
+ print(f"⚠️ GEE initialization failed at startup: {e}")
32
+
33
+ # APP INITIALIZATION
34
+ app = FastAPI(title="Flood Vulnerability Assessment API", version="1.0")
35
+
36
+ # Frontend templates setup
37
+ templates = Jinja2Templates(directory="templates")
38
+
39
+ # Thread pool for batch processing
40
+ executor = ThreadPoolExecutor(max_workers=10)
41
+
42
+
43
+ # DATA MODEL
44
+ class SingleAssessment(BaseModel):
45
+ latitude: float
46
+ longitude: float
47
+ height: Optional[float] = 0.0
48
+ basement: Optional[float] = 0.0
49
+
50
+ @field_validator('latitude')
51
+ @classmethod
52
+ def check_lat(cls, v: float) -> float:
53
+ if not -90 <= v <= 90:
54
+ raise ValueError('Latitude must be between -90 and 90')
55
+ return v
56
+
57
+ @field_validator('longitude')
58
+ @classmethod
59
+ def check_lon(cls, v: float) -> float:
60
+ if not -180 <= v <= 180:
61
+ raise ValueError('Longitude must be between -180 and 180')
62
+ return v
63
+
64
+ @field_validator('basement')
65
+ @classmethod
66
+ def check_basement(cls, v: float) -> float:
67
+ if v > 0:
68
+ raise ValueError('Basement height must be 0 or negative (e.g., -1, -2, -3)')
69
+ return v
70
+
71
+
72
+ # FRONTEND ROUTE
73
+ @app.get("/", response_class=HTMLResponse)
74
+ async def home(request: Request):
75
+ """Serve the main web interface"""
76
+ return templates.TemplateResponse("index.html", {"request": request})
77
+
78
+
79
+ # API ROUTES
80
+ @app.get("/api")
81
+ async def root() -> Dict:
82
+ """API info endpoint"""
83
+ return {
84
+ "service": "Flood Vulnerability Assessment API",
85
+ "version": "1.0",
86
+ "endpoints": {
87
+ "POST /assess": "Assess single location",
88
+ "POST /assess_batch": "Assess batch from CSV file",
89
+ "GET /health": "Health check"
90
+ }
91
+ }
92
+
93
+
94
+ @app.post("/assess")
95
+ async def assess_single(data: SingleAssessment) -> Dict:
96
+ """Assess flood vulnerability for a single location (non-blocking)."""
97
+ loop = asyncio.get_event_loop()
98
+
99
+ try:
100
+ # Run slow terrain + water queries in a background thread
101
+ terrain, water_dist = await loop.run_in_executor(
102
+ None,
103
+ lambda: (
104
+ get_terrain_metrics(data.latitude, data.longitude),
105
+ distance_to_water(data.latitude, data.longitude)
106
+ )
107
+ )
108
+
109
+ # Calculate vulnerability after terrain + water distance retrieved
110
+ result = calculate_vulnerability_index(
111
+ lat=data.latitude,
112
+ lon=data.longitude,
113
+ height=data.height,
114
+ basement=data.basement,
115
+ terrain_metrics=terrain,
116
+ water_distance=water_dist
117
+ )
118
+
119
+ return {
120
+ "status": "success",
121
+ "input": data.dict(),
122
+ "assessment": result
123
+ }
124
+
125
+ except Exception as e:
126
+ raise HTTPException(status_code=500, detail=f"Assessment failed: {e}")
127
+
128
+
129
+ @app.post("/predict_height")
130
+ async def predict_height(data: SingleAssessment) -> Dict:
131
+ try:
132
+ predictor = get_predictor()
133
+ result = predictor.predict_from_coordinates(data.latitude, data.longitude)
134
+
135
+ if result['status'] == 'error':
136
+ raise HTTPException(status_code=500, detail=result['error'])
137
+
138
+ return result
139
+ except Exception as e:
140
+ raise HTTPException(status_code=500, detail=str(e))
141
+
142
+
143
+ def process_single_row(row, use_predicted_height=False):
144
+ """Process a single row from CSV - used for parallel processing."""
145
+ try:
146
+ lat = row['latitude']
147
+ lon = row['longitude']
148
+ height = row.get('height', 0.0)
149
+ basement = row.get('basement', 0.0)
150
+
151
+ if use_predicted_height:
152
+ try:
153
+ predictor = get_predictor()
154
+ pred = predictor.predict_from_coordinates(lat, lon)
155
+ if pred['status'] == 'success' and pred['predicted_height'] is not None:
156
+ height = pred['predicted_height']
157
+ except Exception as e:
158
+ print(f"Height prediction failed for {lat},{lon}: {e}")
159
+
160
+ terrain = get_terrain_metrics(lat, lon)
161
+ water_dist = distance_to_water(lat, lon)
162
+
163
+ result = calculate_vulnerability_index(
164
+ lat=lat,
165
+ lon=lon,
166
+ height=height,
167
+ basement=basement,
168
+ terrain_metrics=terrain,
169
+ water_distance=water_dist
170
+ )
171
+
172
+ # CSV output - essential columns
173
+ return {
174
+ 'latitude': lat,
175
+ 'longitude': lon,
176
+ 'height': height,
177
+ 'basement': basement,
178
+ 'vulnerability_index': result['vulnerability_index'],
179
+ 'ci_lower_95': result['confidence_interval']['lower_bound_95'],
180
+ 'ci_upper_95': result['confidence_interval']['upper_bound_95'],
181
+ 'vulnerability_level': result['risk_level'],
182
+ 'confidence': result['uncertainty_analysis']['confidence'],
183
+ 'confidence_interpretation': result['uncertainty_analysis']['interpretation'],
184
+ 'elevation_m': result['elevation_m'],
185
+ 'tpi_m': result['relative_elevation_m'],
186
+ 'slope_degrees': result['slope_degrees'],
187
+ 'distance_to_water_m': result['distance_to_water_m'],
188
+ 'quality_flags': ','.join(result['uncertainty_analysis']['data_quality_flags']) if result['uncertainty_analysis']['data_quality_flags'] else ''
189
+ }
190
+
191
+ except Exception as e:
192
+ return {
193
+ 'latitude': row.get('latitude'),
194
+ 'longitude': row.get('longitude'),
195
+ 'height': row.get('height', 0.0),
196
+ 'basement': row.get('basement', 0.0),
197
+ 'error': str(e),
198
+ 'vulnerability_index': None,
199
+ 'ci_lower_95': None,
200
+ 'ci_upper_95': None,
201
+ 'risk_level': None,
202
+ 'confidence': None,
203
+ 'confidence_interpretation': None,
204
+ 'elevation_m': None,
205
+ 'tpi_m': None,
206
+ 'slope_degrees': None,
207
+ 'distance_to_water_m': None,
208
+ 'quality_flags': ''
209
+ }
210
+
211
+
212
+ @app.post("/assess_batch")
213
+ async def assess_batch(file: UploadFile = File(...), use_predicted_height: bool = False) -> StreamingResponse:
214
+ """Assess flood vulnerability for multiple locations from a CSV file."""
215
+ try:
216
+ contents = await file.read()
217
+ df = pd.read_csv(io.StringIO(contents.decode('utf-8')))
218
+
219
+ if 'latitude' not in df.columns or 'longitude' not in df.columns:
220
+ raise HTTPException(
221
+ status_code=400,
222
+ detail="CSV must contain 'latitude' and 'longitude' columns"
223
+ )
224
+
225
+ import numpy as np
226
+ df = df[(np.abs(df['latitude']) <= 90) & (np.abs(df['longitude']) <= 180)]
227
+ if len(df) == 0:
228
+ raise HTTPException(status_code=400, detail="No valid coordinates in CSV (lat -90..90, lon -180..180)")
229
+
230
+ # Set defaults for optional columns
231
+ if 'height' not in df.columns:
232
+ df['height'] = 0.0
233
+ if 'basement' not in df.columns:
234
+ df['basement'] = 0.0
235
+
236
+ loop = asyncio.get_event_loop()
237
+ results = await loop.run_in_executor(
238
+ executor,
239
+ lambda: [process_single_row(row, use_predicted_height) for _, row in df.iterrows()]
240
+ )
241
+
242
+ results_df = pd.DataFrame(results)
243
+ output = io.StringIO()
244
+ results_df.to_csv(output, index=False)
245
+ output.seek(0)
246
+ return StreamingResponse(
247
+ io.BytesIO(output.getvalue().encode('utf-8')),
248
+ media_type="text/csv",
249
+ headers={
250
+ "Content-Disposition": (
251
+ "attachment; filename=vulnerability_results.csv; "
252
+ "filename*=UTF-8''vulnerability_results.csv"
253
+ )
254
+ }
255
+ )
256
+
257
+ except Exception as e:
258
+ raise HTTPException(status_code=500, detail=f"Batch processing failed: {str(e)}")
259
+ @app.post("/assess_batch_multihazard")
260
+ async def assess_batch_multihazard(file: UploadFile = File(...), use_predicted_height: bool = False) -> StreamingResponse:
261
+ try:
262
+ contents = await file.read()
263
+ df = pd.read_csv(io.StringIO(contents.decode('utf-8')))
264
+
265
+ if 'latitude' not in df.columns or 'longitude' not in df.columns:
266
+ raise HTTPException(
267
+ status_code=400,
268
+ detail="CSV must contain 'latitude' and 'longitude' columns"
269
+ )
270
+
271
+ loop = asyncio.get_event_loop()
272
+ from vulnerability import calculate_multi_hazard_vulnerability
273
+ results = await loop.run_in_executor(
274
+ executor,
275
+ lambda: [process_single_row_multihazard(row, use_predicted_height) for _, row in df.iterrows()]
276
+ )
277
+
278
+ results_df = pd.DataFrame(results)
279
+ output = io.StringIO()
280
+ results_df.to_csv(output, index=False)
281
+ output.seek(0)
282
+ return StreamingResponse(
283
+ io.BytesIO(output.getvalue().encode('utf-8')),
284
+ media_type="text/csv",
285
+ headers={
286
+ "Content-Disposition": (
287
+ "attachment; filename=multihazard_results.csv; "
288
+ "filename*=UTF-8''multihazard_results.csv"
289
+ )
290
+ }
291
+ )
292
+ except Exception as e:
293
+ raise HTTPException(status_code=500, detail=f"Batch multihazard failed: {str(e)}")
294
+ @app.post("/explain")
295
+ async def explain_assessment(data: SingleAssessment) -> Dict:
296
+ """Assess vulnerability with SHAP explanation"""
297
+ loop = asyncio.get_event_loop()
298
+
299
+ try:
300
+ # Run slow terrain + water queries in a background thread
301
+ terrain, water_dist = await loop.run_in_executor(
302
+ None,
303
+ lambda: (
304
+ get_terrain_metrics(data.latitude, data.longitude),
305
+ distance_to_water(data.latitude, data.longitude)
306
+ )
307
+ )
308
+
309
+ result = calculate_vulnerability_index(
310
+ lat=data.latitude,
311
+ lon=data.longitude,
312
+ height=data.height,
313
+ basement=data.basement,
314
+ terrain_metrics=terrain,
315
+ water_distance=water_dist
316
+ )
317
+
318
+ # Generate explanation if explainer available
319
+ explanation = None
320
+ if explainer:
321
+ try:
322
+ explanation = explainer.explain(result['components'])
323
+ except Exception as e:
324
+ print(f"SHAP explanation failed: {e}")
325
+
326
+ return {
327
+ "status": "success",
328
+ "input": data.dict(),
329
+ "assessment": result,
330
+ "explanation": explanation
331
+ }
332
+
333
+ except Exception as e:
334
+ raise HTTPException(status_code=500, detail=f"Assessment failed: {e}")
335
+
336
+
337
+ def process_single_row_multihazard(row, use_predicted_height=False):
338
+ """Process a single row with multi-hazard assessment."""
339
+ try:
340
+ from vulnerability import calculate_multi_hazard_vulnerability
341
+
342
+ lat = row['latitude']
343
+ lon = row['longitude']
344
+ height = row.get('height', 0.0)
345
+ basement = row.get('basement', 0.0)
346
+
347
+ if use_predicted_height:
348
+ try:
349
+ predictor = get_predictor()
350
+ pred = predictor.predict_from_coordinates(lat, lon)
351
+ if pred['status'] == 'success' and pred['predicted_height'] is not None:
352
+ height = pred['predicted_height']
353
+ except Exception as e:
354
+ print(f"Height prediction failed for {lat},{lon}: {e}")
355
+
356
+ terrain = get_terrain_metrics(lat, lon)
357
+ water_dist = distance_to_water(lat, lon)
358
+
359
+ result = calculate_multi_hazard_vulnerability(
360
+ lat=lat,
361
+ lon=lon,
362
+ height=height,
363
+ basement=basement,
364
+ terrain_metrics=terrain,
365
+ water_distance=water_dist
366
+ )
367
+
368
+ return {
369
+ 'latitude': lat,
370
+ 'longitude': lon,
371
+ 'height': height,
372
+ 'basement': basement,
373
+ 'vulnerability_index': result['vulnerability_index'],
374
+ 'ci_lower_95': result['confidence_interval']['lower_bound_95'],
375
+ 'ci_upper_95': result['confidence_interval']['upper_bound_95'],
376
+ 'vulnerability_level': result['risk_level'],
377
+ 'confidence': result['uncertainty_analysis']['confidence'],
378
+ 'confidence_interpretation': result['uncertainty_analysis']['interpretation'],
379
+ 'elevation_m': result['elevation_m'],
380
+ 'tpi_m': result['relative_elevation_m'],
381
+ 'slope_degrees': result['slope_degrees'],
382
+ 'distance_to_water_m': result['distance_to_water_m'],
383
+ 'dominant_hazard': result['dominant_hazard'],
384
+ 'fluvial_risk': result['hazard_breakdown']['fluvial_riverine'],
385
+ 'coastal_risk': result['hazard_breakdown']['coastal_surge'],
386
+ 'pluvial_risk': result['hazard_breakdown']['pluvial_drainage'],
387
+ 'combined_risk': result['hazard_breakdown']['combined_index'],
388
+ 'quality_flags': ','.join(result['uncertainty_analysis']['data_quality_flags'])
389
+ if result['uncertainty_analysis']['data_quality_flags'] else ''
390
+ }
391
+
392
+ except Exception as e:
393
+ return {
394
+ 'latitude': row.get('latitude'),
395
+ 'longitude': row.get('longitude'),
396
+ 'height': row.get('height', 0.0),
397
+ 'basement': row.get('basement', 0.0),
398
+ 'error': str(e),
399
+ 'vulnerability_index': None
400
+ }
401
+
402
+
403
+ @app.post("/assess_multihazard")
404
+ async def assess_multihazard(data: SingleAssessment) -> Dict:
405
+ """Multi-hazard assessment (fluvial + coastal + pluvial)"""
406
+ loop = asyncio.get_event_loop()
407
+
408
+ try:
409
+ from vulnerability import calculate_multi_hazard_vulnerability
410
+
411
+ # Run slow terrain + water queries in a background thread
412
+ terrain, water_dist = await loop.run_in_executor(
413
+ None,
414
+ lambda: (
415
+ get_terrain_metrics(data.latitude, data.longitude),
416
+ distance_to_water(data.latitude, data.longitude)
417
+ )
418
+ )
419
+
420
+ result = calculate_multi_hazard_vulnerability(
421
+ lat=data.latitude,
422
+ lon=data.longitude,
423
+ height=data.height,
424
+ basement=data.basement,
425
+ terrain_metrics=terrain,
426
+ water_distance=water_dist
427
+ )
428
+
429
+ return {
430
+ "status": "success",
431
+ "input": data.dict(),
432
+ "assessment": result
433
+ }
434
+
435
+ except Exception as e:
436
+ raise HTTPException(status_code=500, detail=f"Assessment failed: {e}")
437
+
438
+
439
+ @app.get("/health")
440
+ async def health_check() -> Dict:
441
+ """Health check endpoint."""
442
+ return {"status": "healthy", "gee_initialized": True}