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

Update vulnerability.py

Browse files
Files changed (1) hide show
  1. vulnerability.py +542 -507
vulnerability.py CHANGED
@@ -1,507 +1,542 @@
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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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.001
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
+
438
+ # Coastal surge risk
439
+ from spatial_queries import check_coastal
440
+
441
+ is_coastal, coast_distance = check_coastal(lat, lon)
442
+
443
+ # Guards against odd inputs
444
+ if coast_distance is None or coast_distance < 0:
445
+ coast_distance = 0.0
446
+ if elevation is None:
447
+ raise ValueError("elevation is required")
448
+ if elevation < 0:
449
+ elevation = 0.0
450
+
451
+ if coast_distance < 5000:
452
+ # Near coast β€” elevation governs risk
453
+ if elevation < 2:
454
+ coastal_risk = 0.99
455
+ elif elevation < 10:
456
+ # Linear decline from 0.99 at 2 m
457
+ coastal_risk = max(0.05, 0.99 + ((0.15 - 0.99) / 8.0) * (elevation - 2.0))
458
+ else:
459
+ coastal_risk = 0.15 # Residual surge
460
+ elif coast_distance < 20000:
461
+ # Distance decay factor
462
+ decay_factor = (coast_distance - 5000.0) / 15000.0
463
+ decay_factor = min(max(decay_factor, 0.0), 1.0)
464
+
465
+ # Base residual
466
+ distance_risk = 0.15 * (1.0 - decay_factor)
467
+
468
+ # Elevation modifier
469
+
470
+ elev_multiplier = 1.0 - (elevation / 10.0)
471
+ elev_multiplier = min(max(elev_multiplier, 0.3), 1.0)
472
+
473
+ coastal_risk = max(0.01, distance_risk * elev_multiplier)
474
+ else:
475
+ coastal_risk = 0.01 # Minimal residual background
476
+
477
+ # Safety clamp
478
+ coastal_risk = min(max(coastal_risk, 0.0), 1.0)
479
+
480
+
481
+ # Pluvial risk – global-friendly (refined)
482
+ tpi = terrain_metrics.get('tpi') or 0
483
+ slope = terrain_metrics.get('slope') or 0
484
+ elev = elevation
485
+ # Clamp inputs
486
+ tpi_clamped = max(min(tpi, 10), -10)
487
+ slope_clamped = max(min(slope, 10), 0)
488
+
489
+ # TPI factor: -10 (deep depression)
490
+ # Mild convexity
491
+ topo_linear = 1.0 - (tpi_clamped + 10) / 20.0
492
+ topo_factor = max(0.0, min(1.0, topo_linear**0.9))
493
+
494
+ # Nonlinear drop
495
+ slope_fraction = 1.0 - (slope_clamped / 10.0)
496
+ slope_factor = max(0.0, min(1.0, slope_fraction**1.2))
497
+
498
+ # Elevation decay:
499
+ if elev <= 200:
500
+ elevation_decay = 1.0
501
+ elif elev <= 1000:
502
+ # linear to 0.1 across 800 m
503
+ elevation_decay = 1.0 - ((elev - 200) / 800.0) * 0.9
504
+ else:
505
+ elevation_decay = 0.1
506
+
507
+ # Combine (weights are tunable)
508
+ pluvial_risk = (topo_factor * 0.6 + slope_factor * 0.4) * elevation_decay
509
+
510
+ # Clamp final risk
511
+ pluvial_risk = min(max(pluvial_risk, 0.0), 1.0)
512
+
513
+ # Combined hazard with adaptive weights
514
+ if elevation < 10: # Coastal zone
515
+ weights = {'fluvial': 0.3, 'coastal': 0.5, 'pluvial': 0.2}
516
+ elif elevation < 100: # Low inland
517
+ weights = {'fluvial': 0.5, 'coastal': 0.1, 'pluvial': 0.4}
518
+ else: # Elevated
519
+ weights = {'fluvial': 0.6, 'coastal': 0.0, 'pluvial': 0.4}
520
+
521
+ combined = (base_result['vulnerability_index'] * weights['fluvial'] +
522
+ coastal_risk * weights['coastal'] +
523
+ pluvial_risk * weights['pluvial'])
524
+
525
+ # Identify dominant hazard
526
+ hazards = {
527
+ 'fluvial_riverine': base_result['vulnerability_index'],
528
+ 'coastal_surge': coastal_risk,
529
+ 'pluvial_drainage': pluvial_risk
530
+ }
531
+ dominant = max(hazards, key=hazards.get)
532
+
533
+ return {
534
+ **base_result,
535
+ 'hazard_breakdown': {
536
+ 'fluvial_riverine': round(base_result['vulnerability_index'], 3),
537
+ 'coastal_surge': round(coastal_risk, 3),
538
+ 'pluvial_drainage': round(pluvial_risk, 3),
539
+ 'combined_index': round(combined, 3)
540
+ },
541
+ 'dominant_hazard': dominant
542
+ }