Vikctor commited on
Commit
aa52ad0
·
verified ·
1 Parent(s): 40ad8a7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +408 -235
app.py CHANGED
@@ -1,6 +1,11 @@
 
 
 
 
 
 
1
  from fastapi import FastAPI, HTTPException
2
  from fastapi.middleware.cors import CORSMiddleware
3
- from fastapi.responses import HTMLResponse
4
  from pydantic import BaseModel, Field, ConfigDict
5
  from typing import Optional, Dict, List, Any
6
  import joblib
@@ -41,142 +46,71 @@ occurrence_transformer = None
41
  occurrence_model = None
42
  severity_transformer = None
43
  severity_model = None
 
44
  USGS_API_BASE = "https://earthquake.usgs.gov/fdsnws/event/1/query"
45
  ELEVATION_API = "https://api.open-elevation.com/api/v1/lookup"
46
  DEFAULT_RADIUS_KM = 100 # Default radius for USGS data fetch
47
 
 
48
  # ============================================================================
49
  # Pydantic Models
50
  # ============================================================================
 
51
  class PredictionRequest(BaseModel):
52
  model_config = ConfigDict(arbitrary_types_allowed=True)
53
-
54
- latitude: float = Field(
55
- ...,
56
- ge=-90,
57
- le=90,
58
- description="Latitude coordinate (-90 to 90)",
59
- examples=[34.0522]
60
- )
61
- longitude: float = Field(
62
- ...,
63
- ge=-180,
64
- le=180,
65
- description="Longitude coordinate (-180 to 180)",
66
- examples=[-118.2437]
67
- )
68
- time: str = Field(
69
- ...,
70
- description="Prediction time in ISO 8601 format",
71
- examples=["2025-10-22T14:00:00", "2025-12-25T12:00:00Z"]
72
- )
73
-
74
- class LocationInfo(BaseModel):
75
- latitude: float = Field(..., description="Latitude coordinate")
76
- longitude: float = Field(..., description="Longitude coordinate")
77
- time: str = Field(..., description="Prediction timestamp")
78
-
79
- class OccurrencePrediction(BaseModel):
80
- will_occur: int = Field(
81
- ...,
82
- ge=0,
83
- le=1,
84
- description="Binary prediction: 0 = No earthquake, 1 = Earthquake expected"
85
- )
86
- confidence: float = Field(
87
- ...,
88
- ge=0.0,
89
- le=1.0,
90
- description="Model confidence score (0.0 to 1.0)"
91
- )
92
-
93
- class SeverityPrediction(BaseModel):
94
- severity_class: int = Field(
95
- ...,
96
- ge=0,
97
- le=1,
98
- description="Severity classification: 0 = Medium, 1 = High"
99
- )
100
- confidence: float = Field(
101
- ...,
102
- ge=0.0,
103
- le=1.0,
104
- description="Model confidence score (0.0 to 1.0)"
105
- )
106
-
107
- class RiskAssessment(BaseModel):
108
- risk_level: int = Field(
109
- ...,
110
- ge=0,
111
- le=2,
112
- description="Numeric risk level: 0 = Very Low, 1 = Moderate, 2 = High"
113
- )
114
- risk_label: str = Field(
115
- ...,
116
- description="Human-readable risk label",
117
- examples=["VERY LOW", "MODERATE", "HIGH"]
118
- )
119
- recommendation: str = Field(
120
- ...,
121
- description="Recommended action based on risk assessment"
122
- )
123
-
124
- class EarthquakeAnalysis(BaseModel):
125
- last_1_day: int = Field(..., description="Number of earthquakes in the last 24 hours")
126
- last_7_days: int = Field(..., description="Number of earthquakes in the last 7 days")
127
- last_30_days: int = Field(..., description="Number of earthquakes in the last 30 days")
128
- last_90_days: int = Field(..., description="Number of earthquakes in the last 90 days")
129
-
130
- class DataQuality(BaseModel):
131
- earthquakes_analyzed: EarthquakeAnalysis
132
- latest_earthquake: str = Field(..., description="Location of most recent earthquake")
133
- data_source: str = Field(..., description="Primary data source")
134
- boundary_type: int = Field(..., description="Tectonic boundary type code")
135
- crust_type: int = Field(..., description="Crust type code")
136
- elevation_m: float = Field(..., description="Elevation in meters")
137
 
138
  class PredictionResponse(BaseModel):
139
  model_config = ConfigDict(arbitrary_types_allowed=True)
140
-
141
- location: LocationInfo
142
- all_features: Dict[str, Any] = Field(..., description="All computed features")
143
- features_for_transformation: Dict[str, float] = Field(..., description="Features used for transformation")
144
- selected_features: Dict[str, float] = Field(..., description="Final features used by model")
145
- occurrence_prediction: OccurrencePrediction
146
- severity_prediction: Optional[SeverityPrediction] = Field(
147
- None,
148
- description="Severity prediction (only if earthquake is predicted)"
149
- )
150
- risk_assessment: RiskAssessment
151
- data_quality: DataQuality
152
- timestamp: str = Field(..., description="Prediction generation timestamp (UTC)")
153
 
154
  # ============================================================================
155
  # Startup Event - Load Models
156
  # ============================================================================
 
157
  @app.on_event("startup")
158
  async def load_models():
 
159
  global occurrence_transformer, occurrence_model
160
  global severity_transformer, severity_model
 
161
  try:
162
  logger.info("Loading transformers...")
163
  occurrence_transformer = joblib.load('occurence_transformer.joblib')
164
  severity_transformer = joblib.load('severity_transformer.joblib')
 
165
  logger.info("Loading occurrence model...")
166
  occurrence_model = catboost.CatBoostClassifier()
167
  occurrence_model.load_model('occurence_model.cbm')
 
168
  logger.info("Loading severity model...")
169
  severity_model = catboost.CatBoostClassifier()
170
  severity_model.load_model('severity_model.cbm')
 
171
  logger.info("All models loaded successfully!")
172
  logger.info(f"Transformer expects: {list(occurrence_transformer.feature_names_in_)}")
 
173
  except Exception as e:
174
  logger.error(f"Error loading models: {e}")
175
  raise
176
 
 
177
  # ============================================================================
178
  # USGS Data Fetching Functions
179
  # ============================================================================
 
180
  def fetch_usgs_earthquakes(
181
  latitude: float,
182
  longitude: float,
@@ -185,6 +119,9 @@ def fetch_usgs_earthquakes(
185
  end_time: datetime,
186
  min_magnitude: float = 0.0
187
  ) -> List[Dict]:
 
 
 
188
  params = {
189
  'format': 'geojson',
190
  'latitude': latitude,
@@ -193,169 +130,297 @@ def fetch_usgs_earthquakes(
193
  'starttime': start_time.strftime('%Y-%m-%dT%H:%M:%S'),
194
  'endtime': end_time.strftime('%Y-%m-%dT%H:%M:%S'),
195
  'minmagnitude': min_magnitude,
196
- 'orderby': 'time-desc' # Newer first for easier latest access
197
  }
 
198
  try:
199
  logger.info(f"Fetching earthquakes from USGS API...")
 
 
 
 
200
  response = requests.get(USGS_API_BASE, params=params, timeout=30)
201
  response.raise_for_status()
 
202
  data = response.json()
203
  earthquakes = []
 
204
  if 'features' in data:
205
  for feature in data['features']:
206
  props = feature['properties']
207
  coords = feature['geometry']['coordinates']
 
208
  earthquakes.append({
209
- 'magnitude': props.get('mag') or 0.0,
210
  'latitude': coords[1],
211
  'longitude': coords[0],
212
  'depth': coords[2],
213
  'time': datetime.fromtimestamp(props['time'] / 1000),
214
  'place': props.get('place', 'Unknown')
215
  })
216
- logger.info(f"Found {len(earthquakes)} earthquakes")
 
217
  return earthquakes
 
218
  except requests.exceptions.RequestException as e:
219
  logger.error(f"Error fetching USGS data: {e}")
220
  return []
221
 
 
222
  def get_elevation(latitude: float, longitude: float) -> float:
 
 
 
223
  try:
224
- params = {'locations': f"{latitude},{longitude}"}
 
 
225
  response = requests.get(ELEVATION_API, params=params, timeout=10)
226
  response.raise_for_status()
227
  data = response.json()
228
- if data.get('results'):
229
- return float(data['results'][0]['elevation'])
 
 
 
230
  except Exception as e:
231
  logger.warning(f"Could not fetch elevation: {e}")
232
- return 0.0
 
233
 
234
  # ============================================================================
235
  # Tectonic and Geological Functions
236
  # ============================================================================
 
237
  BOUNDARIES_FILE = "tectonicplates-master/PB2002_steps.shp"
238
  try:
239
  BOUNDARIES = gpd.read_file(BOUNDARIES_FILE)
 
 
 
240
  step_class_col = next((col for col in BOUNDARIES.columns if 'stepclass' in col.lower()), None)
241
  if step_class_col and step_class_col != 'StepClass':
 
242
  BOUNDARIES = BOUNDARIES.rename(columns={step_class_col: 'StepClass'})
243
  except Exception as e:
244
  logger.error(f"Failed to load PB2002 steps: {e}")
245
  raise
246
 
 
247
  def simplify_boundary_type(bt: str) -> int:
 
248
  boundary_types = {
249
- 'SUB': 0, 'OCB': 0, 'CCB': 0, # Convergent
250
- 'OSR': 1, 'CRB': 1, # Divergent
251
- 'OTF': 2, 'CTF': 2 # Transform
 
 
 
 
252
  }
253
- return boundary_types.get(bt, 3)
 
 
 
 
 
 
 
 
 
 
 
254
 
255
- def determine_boundary_type(latitude: float, longitude: float, max_distance_km: float = 1000.0) -> int:
256
  point = Point(longitude, latitude)
257
  min_distance = float('inf')
258
  closest_type = 3
 
 
 
 
259
  if 'StepClass' in BOUNDARIES.columns:
260
- for _, row in BOUNDARIES.iterrows():
261
- distance = row.geometry.distance(point) * 111.0 # Approx km
262
- code = row.get('StepClass')
263
- if code and distance < min_distance and distance <= max_distance_km:
 
 
 
264
  min_distance = distance
 
265
  closest_type = simplify_boundary_type(code)
266
- if closest_type == 3: # Fallback
 
 
 
 
 
 
267
  known_boundaries = [
268
- (36.0, -121.0, 2), (38.0, 142.0, 0), (-15.0, -75.0, 0),
269
- (37.0, 29.0, 2), (28.0, 85.0, 0), (-41.0, 174.0, 2),
270
- (61.0, -147.0, 0), (19.0, -155.0, 1)
 
 
 
 
 
271
  ]
272
- for b_lat, b_lon, b_type in known_boundaries:
273
- dist = haversine_distance(latitude, longitude, b_lat, b_lon)
274
- if dist < min_distance and dist <= max_distance_km:
275
- min_distance = dist
276
- closest_type = b_type
 
 
 
 
277
  return closest_type
278
 
 
279
  def determine_crust_type(elevation: float) -> int:
 
 
 
280
  return 0 if elevation < 0 else 1
281
 
 
282
  # ============================================================================
283
  # Feature Engineering Functions
284
  # ============================================================================
 
285
  def calculate_seismic_energy(magnitude: float) -> float:
 
 
 
286
  return 10 ** (1.5 * magnitude + 4.8)
287
 
 
288
  def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
289
- R = 6371.0
 
 
 
290
  lat1_rad = math.radians(lat1)
291
  lat2_rad = math.radians(lat2)
292
  dlat = math.radians(lat2 - lat1)
293
  dlon = math.radians(lon2 - lon1)
294
- a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
 
295
  c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
296
  return R * c
297
 
 
298
  def estimate_distance_to_boundary(latitude: float, longitude: float) -> float:
 
 
 
299
  active_zones = [
300
- (36.0, -121.0), (38.0, 142.0), (-15.0, -75.0),
301
- (37.0, 29.0), (28.0, 85.0), (-41.0, 174.0),
302
- (61.0, -147.0), (19.0, -155.0)
 
 
 
 
 
303
  ]
304
- return min(haversine_distance(latitude, longitude, z[0], z[1]) for z in active_zones)
 
 
 
 
 
305
 
306
- def compute_all_features(latitude: float, longitude: float, prediction_time: datetime):
307
- earthquakes_1d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM,
308
- prediction_time - timedelta(days=1), prediction_time)
309
- earthquakes_7d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM,
310
- prediction_time - timedelta(days=7), prediction_time)
 
 
 
 
 
 
 
 
 
 
 
 
311
  earthquakes_30d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM,
312
  prediction_time - timedelta(days=30), prediction_time)
313
  earthquakes_90d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM,
314
  prediction_time - timedelta(days=90), prediction_time)
315
 
316
- all_features = {}
 
 
317
 
318
- # Helper to compute stats
319
- def compute_stats(eq_list):
320
- if not eq_list:
321
- return 0.0, 0.0, 0.0
322
- mags = [eq['magnitude'] for eq in eq_list]
323
- mean_mag = np.mean(mags)
324
- max_mag = np.max(mags)
325
- total_energy = sum(calculate_seismic_energy(m) for m in mags)
326
- log_energy = np.log10(total_energy + 1e-10) # Avoid log(0)
327
- return mean_mag, max_mag, log_energy
328
-
329
- # 1d
330
  all_features['count_prev_1d'] = len(earthquakes_1d)
331
- all_features['meanmag_prev_1d'], all_features['maxmag_prev_1d'], all_features['log_energy_prev_1d'] = compute_stats(earthquakes_1d)
 
 
 
 
 
 
 
 
 
332
 
333
- # 7d
334
  all_features['count_prev_7d'] = len(earthquakes_7d)
335
- all_features['meanmag_prev_7d'], all_features['maxmag_prev_7d'], all_features['log_energy_prev_7d'] = compute_stats(earthquakes_7d)
 
 
 
 
 
 
 
 
 
336
 
337
- # 30d
338
  all_features['count_prev_30d'] = len(earthquakes_30d)
339
- all_features['meanmag_prev_30d'], all_features['maxmag_prev_30d'], all_features['log_energy_prev_30d'] = compute_stats(earthquakes_30d)
 
 
 
 
 
 
 
 
 
340
 
341
- # 90d
342
  all_features['count_prev_90d'] = len(earthquakes_90d)
343
- all_features['meanmag_prev_90d'], all_features['maxmag_prev_90d'], all_features['log_energy_prev_90d'] = compute_stats(earthquakes_90d)
 
 
 
 
 
 
 
 
 
344
 
345
- # Days since last
346
  if earthquakes_7d:
347
- latest = earthquakes_7d[0] # Already sorted desc by time
348
- days_since = (prediction_time - latest['time']).total_seconds() / 86400.0
 
349
  else:
350
- days_since = 7.0
351
- all_features['days_since_last_event'] = days_since
352
 
353
- # Rate change
354
  rate_7d = all_features['count_prev_7d'] / 7.0
355
- rate_30d = all_features['count_prev_30d'] / 30.0 if all_features['count_prev_30d'] > 0 else 1e-6
356
- all_features['rate_change_7d_vs_30d'] = (rate_7d - rate_30d) / rate_30d
 
 
 
357
 
358
- # Geological
359
  elevation = get_elevation(latitude, longitude)
360
  all_features['dist_to_boundary_km'] = estimate_distance_to_boundary(latitude, longitude)
361
  all_features['boundary_type'] = determine_boundary_type(latitude, longitude)
@@ -363,7 +428,14 @@ def compute_all_features(latitude: float, longitude: float, prediction_time: dat
363
  all_features['elevation_m'] = elevation
364
  all_features['month'] = prediction_time.month
365
 
366
- # For transformation
 
 
 
 
 
 
 
367
  transformation_features = {
368
  'count_prev_1d': all_features['count_prev_1d'],
369
  'meanmag_prev_1d': all_features['meanmag_prev_1d'],
@@ -380,136 +452,237 @@ def compute_all_features(latitude: float, longitude: float, prediction_time: dat
380
  'dist_to_boundary_km': all_features['dist_to_boundary_km']
381
  }
382
 
 
 
 
 
 
 
 
 
383
  month_sin = np.sin(2 * np.pi * prediction_time.month / 12)
384
  month_cos = np.cos(2 * np.pi * prediction_time.month / 12)
385
 
386
- latest_place = earthquakes_7d[0]['place'] if earthquakes_7d else "None in past 7 days"
 
 
387
 
388
  data_info = {
389
  'earthquakes_1d': len(earthquakes_1d),
390
  'earthquakes_7d': len(earthquakes_7d),
391
  'earthquakes_30d': len(earthquakes_30d),
392
  'earthquakes_90d': len(earthquakes_90d),
393
- 'latest_place': latest_place
394
  }
395
 
396
  return all_features, transformation_features, month_sin, month_cos, data_info
397
 
 
398
  # ============================================================================
399
  # API Endpoints
400
  # ============================================================================
401
- @app.get("/", response_class=HTMLResponse)
 
402
  async def root():
403
- # (Your full HTML content here - unchanged, omitted for brevity but keep it exactly as provided)
404
- html_content = """...""" # Paste the entire HTML from your original code here
405
- return html_content
 
 
 
 
 
 
 
 
 
 
406
 
407
  @app.post("/predict", response_model=PredictionResponse)
408
  async def predict_earthquake(request: PredictionRequest):
 
 
 
409
  try:
410
- # Handle ISO time with or without Z/timezone
411
- time_str = request.time
412
- if time_str.endswith('Z'):
413
- time_str = time_str[:-1] + '+00:00'
414
- prediction_time = datetime.fromisoformat(time_str)
 
 
 
 
 
 
 
415
 
416
  all_features, transformation_features, month_sin, month_cos, data_info = compute_all_features(
417
- request.latitude, request.longitude, prediction_time
 
 
418
  )
419
 
420
- # Transformation
421
- transformer_feature_names = list(occurrence_transformer.feature_names_in_)
422
- df_transform = pd.DataFrame([transformation_features])[transformer_feature_names]
423
- transformed = occurrence_transformer.transform(df_transform)[0]
 
 
 
 
424
 
425
- transformed_dict = {name: transformed[i] for i, name in enumerate(transformer_feature_names)}
 
 
 
 
 
 
 
 
 
 
 
426
 
427
- # Selected features
428
  selected_features = {
429
- 'meanmag_prev_1d': transformed_dict.get('meanmag_prev_1d', 0.0),
430
- 'maxmag_prev_1d': transformed_dict.get('maxmag_prev_1d', 0.0),
431
- 'meanmag_prev_7d': transformed_dict.get('meanmag_prev_7d', 0.0),
432
- 'log_energy_prev_7d': transformed_dict.get('log_energy_prev_7d', 0.0),
433
  'meanmag_prev_30d': all_features['meanmag_prev_30d'],
434
  'log_energy_prev_30d': all_features['log_energy_prev_30d'],
435
  'meanmag_prev_90d': all_features['meanmag_prev_90d'],
436
  'log_energy_prev_90d': all_features['log_energy_prev_90d'],
437
- 'days_since_last_event': transformed_dict.get('days_since_last_event', 0.0),
438
- 'rate_change_7d_vs_30d': transformed_dict.get('rate_change_7d_vs_30d', 0.0),
439
- 'dist_to_boundary_km': transformed_dict.get('dist_to_boundary_km', 0.0),
440
  'elevation_m': all_features['elevation_m'],
441
- 'boundary_type': float(all_features['boundary_type']), # CatBoost can handle int, but float ok
442
- 'crust_type': float(all_features['crust_type']),
443
  'month_sin': month_sin,
444
  'month_cos': month_cos
445
  }
446
 
 
 
 
 
 
 
 
 
447
  final_df = pd.DataFrame([selected_features])
 
 
448
 
449
- # Predictions
450
- occurrence_pred = occurrence_model.predict(final_df)[0][0] if occurrence_model.get_prediction_type() == 'RawFormulaVal' else occurrence_model.predict(final_df)[0]
451
- occurrence_proba = occurrence_model.predict_proba(final_df)[0]
452
- will_occur = int(occurrence_pred)
453
- confidence = float(occurrence_proba[1])
 
 
 
 
 
 
 
 
454
 
455
  severity_result = None
456
  if will_occur:
 
457
  severity_pred = severity_model.predict(final_df)[0]
458
- severity_proba = severity_model.predict_proba(final_df)[0]
459
- severity_class = int(severity_pred)
460
- severity_conf = float(severity_proba[severity_class])
461
- severity_result = SeverityPrediction(severity_class=severity_class, confidence=severity_conf)
462
-
463
- # Risk
464
- if will_occur and severity_result and severity_result.severity_class == 1:
465
- risk_level = 2
466
- risk_label = "HIGH"
467
- recommendation = "Immediate evacuation and emergency preparedness required. Follow local emergency services guidance."
468
- elif will_occur:
469
- risk_level = 1
470
- risk_label = "MODERATE"
471
- recommendation = "Stay alert and prepare emergency supplies. Monitor local alerts and be ready to act."
 
 
 
 
472
  else:
473
- risk_level = 0
474
- risk_label = "VERY LOW"
475
- recommendation = "No significant seismic activity expected. Continue normal activities."
476
-
477
- return PredictionResponse(
478
- location=LocationInfo(latitude=request.latitude, longitude=request.longitude, time=request.time),
 
 
 
 
 
 
479
  all_features=all_features,
480
  features_for_transformation=transformation_features,
481
  selected_features=selected_features,
482
- occurrence_prediction=OccurrencePrediction(will_occur=will_occur, confidence=confidence),
 
 
 
483
  severity_prediction=severity_result,
484
- risk_assessment=RiskAssessment(risk_level=risk_level, risk_label=risk_label, recommendation=recommendation),
485
- data_quality=DataQuality(
486
- earthquakes_analyzed=EarthquakeAnalysis(
487
- last_1_day=data_info['earthquakes_1d'],
488
- last_7_days=data_info['earthquakes_7d'],
489
- last_30_days=data_info['earthquakes_30d'],
490
- last_90_days=data_info['earthquakes_90d']
491
- ),
492
- latest_earthquake=data_info['latest_place'],
493
- data_source="USGS Earthquake Catalog",
494
- boundary_type=all_features['boundary_type'],
495
- crust_type=all_features['crust_type'],
496
- elevation_m=all_features['elevation_m']
497
- ),
 
 
 
 
498
  timestamp=datetime.utcnow().isoformat()
499
  )
 
 
 
 
 
500
  except Exception as e:
501
- logger.error(f"Prediction error: {e}", exc_info=True)
502
  raise HTTPException(status_code=500, detail=str(e))
503
 
504
- @app.get("/health")
505
- @app.head("/health")
506
  async def health_check():
 
507
  return {
508
  "status": "healthy",
509
- "models_loaded": all(x is not None for x in [occurrence_transformer, occurrence_model, severity_transformer, severity_model]),
 
 
 
 
 
 
 
 
 
510
  "timestamp": datetime.utcnow().isoformat()
511
  }
512
 
 
513
  if __name__ == "__main__":
514
  import uvicorn
 
515
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ """
2
+ FastAPI Earthquake Prediction System
3
+ Uses real-time USGS earthquake data to compute features and make predictions
4
+ Complete feature pipeline with correct transformations
5
+ """
6
+
7
  from fastapi import FastAPI, HTTPException
8
  from fastapi.middleware.cors import CORSMiddleware
 
9
  from pydantic import BaseModel, Field, ConfigDict
10
  from typing import Optional, Dict, List, Any
11
  import joblib
 
46
  occurrence_model = None
47
  severity_transformer = None
48
  severity_model = None
49
+
50
  USGS_API_BASE = "https://earthquake.usgs.gov/fdsnws/event/1/query"
51
  ELEVATION_API = "https://api.open-elevation.com/api/v1/lookup"
52
  DEFAULT_RADIUS_KM = 100 # Default radius for USGS data fetch
53
 
54
+
55
  # ============================================================================
56
  # Pydantic Models
57
  # ============================================================================
58
+
59
  class PredictionRequest(BaseModel):
60
  model_config = ConfigDict(arbitrary_types_allowed=True)
61
+ latitude: float = Field(..., ge=-90, le=90, description="Latitude (-90 to 90)")
62
+ longitude: float = Field(..., ge=-180, le=180, description="Longitude (-180 to 180)")
63
+ time: str = Field(..., description="Prediction time in ISO format (e.g., '2025-10-22T14:00:00')")
64
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  class PredictionResponse(BaseModel):
67
  model_config = ConfigDict(arbitrary_types_allowed=True)
68
+ location: Dict[str, Any]
69
+ all_features: Dict[str, Any]
70
+ features_for_transformation: Dict[str, float]
71
+ selected_features: Dict[str, float]
72
+ occurrence_prediction: Dict[str, Any] # Allows float for confidence
73
+ severity_prediction: Optional[Dict[str, Any]]
74
+ risk_assessment: Dict[str, str]
75
+ data_quality: Dict[str, Any]
76
+ timestamp: str
77
+
 
 
 
78
 
79
  # ============================================================================
80
  # Startup Event - Load Models
81
  # ============================================================================
82
+
83
  @app.on_event("startup")
84
  async def load_models():
85
+ """Load all models and transformers on startup"""
86
  global occurrence_transformer, occurrence_model
87
  global severity_transformer, severity_model
88
+
89
  try:
90
  logger.info("Loading transformers...")
91
  occurrence_transformer = joblib.load('occurence_transformer.joblib')
92
  severity_transformer = joblib.load('severity_transformer.joblib')
93
+
94
  logger.info("Loading occurrence model...")
95
  occurrence_model = catboost.CatBoostClassifier()
96
  occurrence_model.load_model('occurence_model.cbm')
97
+
98
  logger.info("Loading severity model...")
99
  severity_model = catboost.CatBoostClassifier()
100
  severity_model.load_model('severity_model.cbm')
101
+
102
  logger.info("All models loaded successfully!")
103
  logger.info(f"Transformer expects: {list(occurrence_transformer.feature_names_in_)}")
104
+
105
  except Exception as e:
106
  logger.error(f"Error loading models: {e}")
107
  raise
108
 
109
+
110
  # ============================================================================
111
  # USGS Data Fetching Functions
112
  # ============================================================================
113
+
114
  def fetch_usgs_earthquakes(
115
  latitude: float,
116
  longitude: float,
 
119
  end_time: datetime,
120
  min_magnitude: float = 0.0
121
  ) -> List[Dict]:
122
+ """
123
+ Fetch earthquake data from USGS API
124
+ """
125
  params = {
126
  'format': 'geojson',
127
  'latitude': latitude,
 
130
  'starttime': start_time.strftime('%Y-%m-%dT%H:%M:%S'),
131
  'endtime': end_time.strftime('%Y-%m-%dT%H:%M:%S'),
132
  'minmagnitude': min_magnitude,
133
+ 'orderby': 'time'
134
  }
135
+
136
  try:
137
  logger.info(f"Fetching earthquakes from USGS API...")
138
+ logger.info(f" Location: ({latitude}, {longitude})")
139
+ logger.info(f" Radius: {radius_km} km")
140
+ logger.info(f" Time range: {start_time} to {end_time}")
141
+
142
  response = requests.get(USGS_API_BASE, params=params, timeout=30)
143
  response.raise_for_status()
144
+
145
  data = response.json()
146
  earthquakes = []
147
+
148
  if 'features' in data:
149
  for feature in data['features']:
150
  props = feature['properties']
151
  coords = feature['geometry']['coordinates']
152
+
153
  earthquakes.append({
154
+ 'magnitude': props.get('mag', 0),
155
  'latitude': coords[1],
156
  'longitude': coords[0],
157
  'depth': coords[2],
158
  'time': datetime.fromtimestamp(props['time'] / 1000),
159
  'place': props.get('place', 'Unknown')
160
  })
161
+
162
+ logger.info(f" Found {len(earthquakes)} earthquakes")
163
  return earthquakes
164
+
165
  except requests.exceptions.RequestException as e:
166
  logger.error(f"Error fetching USGS data: {e}")
167
  return []
168
 
169
+
170
  def get_elevation(latitude: float, longitude: float) -> float:
171
+ """
172
+ Get elevation for a location using Open-Elevation API
173
+ """
174
  try:
175
+ params = {
176
+ 'locations': f"{latitude},{longitude}"
177
+ }
178
  response = requests.get(ELEVATION_API, params=params, timeout=10)
179
  response.raise_for_status()
180
  data = response.json()
181
+
182
+ if 'results' in data and len(data['results']) > 0:
183
+ elevation = data['results'][0]['elevation']
184
+ logger.info(f"Elevation: {elevation}m")
185
+ return float(elevation)
186
  except Exception as e:
187
  logger.warning(f"Could not fetch elevation: {e}")
188
+ return 0.0
189
+
190
 
191
  # ============================================================================
192
  # Tectonic and Geological Functions
193
  # ============================================================================
194
+
195
  BOUNDARIES_FILE = "tectonicplates-master/PB2002_steps.shp"
196
  try:
197
  BOUNDARIES = gpd.read_file(BOUNDARIES_FILE)
198
+ logger.info(f"Successfully loaded PB2002 steps with {len(BOUNDARIES)} records")
199
+ logger.info(f"Available columns: {list(BOUNDARIES.columns)}")
200
+ # Ensure STEPCLASS is correctly recognized (case-insensitive match)
201
  step_class_col = next((col for col in BOUNDARIES.columns if 'stepclass' in col.lower()), None)
202
  if step_class_col and step_class_col != 'StepClass':
203
+ logger.info(f"Renaming {step_class_col} to StepClass")
204
  BOUNDARIES = BOUNDARIES.rename(columns={step_class_col: 'StepClass'})
205
  except Exception as e:
206
  logger.error(f"Failed to load PB2002 steps: {e}")
207
  raise
208
 
209
+
210
  def simplify_boundary_type(bt: str) -> int:
211
+ """Map PB2002 STEPCLASS to integer based on simplified categories."""
212
  boundary_types = {
213
+ 'SUB': 0, # Subduction (Convergent)
214
+ 'OCB': 0, # Oceanic Convergent Boundary (Convergent)
215
+ 'CCB': 0, # Continental Convergent Boundary (Convergent)
216
+ 'OSR': 1, # Oceanic Spreading Ridge (Divergent)
217
+ 'CRB': 1, # Continental Rift Boundary (Divergent)
218
+ 'OTF': 2, # Oceanic Transform Fault (Transform)
219
+ 'CTF': 2 # Continental Transform Fault (Transform)
220
  }
221
+ return boundary_types.get(bt, 3) # Default to 3 (other) for unrecognized types
222
+
223
+
224
+ def determine_boundary_type(latitude: float, longitude: float, max_distance_km: float = 1000000000000.0) -> int:
225
+ """
226
+ Determine tectonic boundary type using PB2002 STEPCLASS and fallback to proximity.
227
+ Returns encoded integer: 0=convergent, 1=divergent, 2=transform, 3=other
228
+ """
229
+ if not (-90 <= latitude <= 90):
230
+ raise ValueError(f"Latitude {latitude} must be between -90 and 90 degrees")
231
+ if not (-180 <= longitude <= 180):
232
+ raise ValueError(f"Longitude {longitude} must be between -180 and 180 degrees")
233
 
 
234
  point = Point(longitude, latitude)
235
  min_distance = float('inf')
236
  closest_type = 3
237
+ closest_code = None
238
+
239
+ logger.info(f"Checking boundaries for location ({latitude}, {longitude})")
240
+
241
  if 'StepClass' in BOUNDARIES.columns:
242
+ for idx, row in BOUNDARIES.iterrows():
243
+ distance = row.geometry.distance(point) * 111 # Approximate km
244
+ code = row.get('StepClass', None)
245
+ if code is None:
246
+ logger.warning(f"Empty StepClass at index {idx}")
247
+ continue
248
+ if distance <= max_distance_km and distance < min_distance:
249
  min_distance = distance
250
+ closest_code = code
251
  closest_type = simplify_boundary_type(code)
252
+ logger.info(f"PB2002 result: code={closest_code}, type={closest_type}, distance={min_distance:.2f} km")
253
+ else:
254
+ logger.warning("No StepClass column found, using fallback logic")
255
+
256
+ # Fallback logic
257
+ if closest_type == 3:
258
+ logger.info("Using fallback logic for boundary type based on proximity")
259
  known_boundaries = [
260
+ (36.0, -121.0, 2, "San Andreas Fault"), # Transform
261
+ (38.0, 142.0, 0, "Japan Trench"), # Convergent
262
+ (-15.0, -75.0, 0, "Peru-Chile Trench"), # Convergent
263
+ (37.0, 29.0, 2, "North Anatolian Fault"), # Transform
264
+ (28.0, 85.0, 0, "Himalayan Front"), # Convergent
265
+ (-41.0, 174.0, 2, "Alpine Fault"), # Transform
266
+ (61.0, -147.0, 0, "Alaska"), # Convergent
267
+ (19.0, -155.0, 1, "Hawaii") # Divergent
268
  ]
269
+ for boundary_lat, boundary_lon, boundary_type, name in known_boundaries:
270
+ distance = haversine_distance(latitude, longitude, boundary_lat, boundary_lon)
271
+ logger.info(f" {name}: {distance:.2f} km, type={boundary_type}")
272
+ if distance <= max_distance_km and distance < min_distance:
273
+ min_distance = distance
274
+ closest_type = boundary_type
275
+ closest_code = f"Fallback_{name}"
276
+ logger.info(f"Fallback result: type={closest_type}, code={closest_code}, distance={min_distance:.2f} km")
277
+
278
  return closest_type
279
 
280
+
281
  def determine_crust_type(elevation: float) -> int:
282
+ """
283
+ Determine crust type based on elevation: 0=oceanic (elevation < 0), 1=continental (elevation >= 0)
284
+ """
285
  return 0 if elevation < 0 else 1
286
 
287
+
288
  # ============================================================================
289
  # Feature Engineering Functions
290
  # ============================================================================
291
+
292
  def calculate_seismic_energy(magnitude: float) -> float:
293
+ """
294
+ Calculate seismic energy from magnitude using the Gutenberg-Richter relation
295
+ """
296
  return 10 ** (1.5 * magnitude + 4.8)
297
 
298
+
299
  def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
300
+ """
301
+ Calculate the great circle distance between two points on Earth (in kilometers)
302
+ """
303
+ R = 6371
304
  lat1_rad = math.radians(lat1)
305
  lat2_rad = math.radians(lat2)
306
  dlat = math.radians(lat2 - lat1)
307
  dlon = math.radians(lon2 - lon1)
308
+ a = (math.sin(dlat / 2) ** 2 +
309
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2)
310
  c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
311
  return R * c
312
 
313
+
314
  def estimate_distance_to_boundary(latitude: float, longitude: float) -> float:
315
+ """
316
+ Estimate distance to nearest tectonic plate boundary using hardcoded active zones
317
+ """
318
  active_zones = [
319
+ (36.0, -121.0), # San Andreas Fault
320
+ (38.0, 142.0), # Japan Trench
321
+ (-15.0, -75.0), # Peru-Chile Trench
322
+ (37.0, 29.0), # North Anatolian Fault
323
+ (28.0, 85.0), # Himalayan Front
324
+ (-41.0, 174.0), # Alpine Fault
325
+ (61.0, -147.0), # Alaska
326
+ (19.0, -155.0), # Hawaii
327
  ]
328
+ min_distance = float('inf')
329
+ for zone_lat, zone_lon in active_zones:
330
+ distance = haversine_distance(latitude, longitude, zone_lat, zone_lon)
331
+ min_distance = min(min_distance, distance)
332
+ logger.info(f"Estimated distance to nearest boundary: {min_distance:.2f} km")
333
+ return min_distance
334
 
335
+
336
+ def compute_all_features(
337
+ latitude: float,
338
+ longitude: float,
339
+ prediction_time: datetime
340
+ ) -> tuple:
341
+ """
342
+ Compute ALL features in the pipeline
343
+ """
344
+ logger.info("=" * 80)
345
+ logger.info("STEP 1: Fetching historical earthquake data from USGS")
346
+ logger.info("=" * 80)
347
+
348
+ earthquakes_1d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM, prediction_time - timedelta(days=1),
349
+ prediction_time)
350
+ earthquakes_7d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM, prediction_time - timedelta(days=7),
351
+ prediction_time)
352
  earthquakes_30d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM,
353
  prediction_time - timedelta(days=30), prediction_time)
354
  earthquakes_90d = fetch_usgs_earthquakes(latitude, longitude, DEFAULT_RADIUS_KM,
355
  prediction_time - timedelta(days=90), prediction_time)
356
 
357
+ logger.info("=" * 80)
358
+ logger.info("STEP 2: Computing ALL features")
359
+ logger.info("=" * 80)
360
 
361
+ all_features = {}
 
 
 
 
 
 
 
 
 
 
 
362
  all_features['count_prev_1d'] = len(earthquakes_1d)
363
+ if earthquakes_1d:
364
+ magnitudes_1d = [eq['magnitude'] for eq in earthquakes_1d]
365
+ all_features['meanmag_prev_1d'] = np.mean(magnitudes_1d)
366
+ all_features['maxmag_prev_1d'] = np.max(magnitudes_1d)
367
+ total_energy_1d = sum(calculate_seismic_energy(m) for m in magnitudes_1d)
368
+ all_features['log_energy_prev_1d'] = np.log10(total_energy_1d) if total_energy_1d > 0 else 0
369
+ else:
370
+ all_features['meanmag_prev_1d'] = 0.0
371
+ all_features['maxmag_prev_1d'] = 0.0
372
+ all_features['log_energy_prev_1d'] = 0.0
373
 
 
374
  all_features['count_prev_7d'] = len(earthquakes_7d)
375
+ if earthquakes_7d:
376
+ magnitudes_7d = [eq['magnitude'] for eq in earthquakes_7d]
377
+ all_features['meanmag_prev_7d'] = np.mean(magnitudes_7d)
378
+ all_features['maxmag_prev_7d'] = np.max(magnitudes_7d)
379
+ total_energy_7d = sum(calculate_seismic_energy(m) for m in magnitudes_7d)
380
+ all_features['log_energy_prev_7d'] = np.log10(total_energy_7d) if total_energy_7d > 0 else 0
381
+ else:
382
+ all_features['meanmag_prev_7d'] = 0.0
383
+ all_features['maxmag_prev_7d'] = 0.0
384
+ all_features['log_energy_prev_7d'] = 0.0
385
 
 
386
  all_features['count_prev_30d'] = len(earthquakes_30d)
387
+ if earthquakes_30d:
388
+ magnitudes_30d = [eq['magnitude'] for eq in earthquakes_30d]
389
+ all_features['meanmag_prev_30d'] = np.mean(magnitudes_30d)
390
+ all_features['maxmag_prev_30d'] = np.max(magnitudes_30d)
391
+ total_energy_30d = sum(calculate_seismic_energy(m) for m in magnitudes_30d)
392
+ all_features['log_energy_prev_30d'] = np.log10(total_energy_30d) if total_energy_30d > 0 else 0
393
+ else:
394
+ all_features['meanmag_prev_30d'] = 0.0
395
+ all_features['maxmag_prev_30d'] = 0.0
396
+ all_features['log_energy_prev_30d'] = 0.0
397
 
 
398
  all_features['count_prev_90d'] = len(earthquakes_90d)
399
+ if earthquakes_90d:
400
+ magnitudes_90d = [eq['magnitude'] for eq in earthquakes_90d]
401
+ all_features['meanmag_prev_90d'] = np.mean(magnitudes_90d)
402
+ all_features['maxmag_prev_90d'] = np.max(magnitudes_90d)
403
+ total_energy_90d = sum(calculate_seismic_energy(m) for m in magnitudes_90d)
404
+ all_features['log_energy_prev_90d'] = np.log10(total_energy_90d) if total_energy_90d > 0 else 0
405
+ else:
406
+ all_features['meanmag_prev_90d'] = 0.0
407
+ all_features['maxmag_prev_90d'] = 0.0
408
+ all_features['log_energy_prev_90d'] = 0.0
409
 
 
410
  if earthquakes_7d:
411
+ latest_earthquake = max(earthquakes_7d, key=lambda x: x['time'])
412
+ days_since = (prediction_time - latest_earthquake['time']).total_seconds() / 86400
413
+ all_features['days_since_last_event'] = days_since
414
  else:
415
+ all_features['days_since_last_event'] = 7.0
 
416
 
 
417
  rate_7d = all_features['count_prev_7d'] / 7.0
418
+ rate_30d = all_features['count_prev_30d'] / 30.0
419
+ if rate_30d > 0:
420
+ all_features['rate_change_7d_vs_30d'] = (rate_7d - rate_30d) / rate_30d
421
+ else:
422
+ all_features['rate_change_7d_vs_30d'] = 0.0
423
 
 
424
  elevation = get_elevation(latitude, longitude)
425
  all_features['dist_to_boundary_km'] = estimate_distance_to_boundary(latitude, longitude)
426
  all_features['boundary_type'] = determine_boundary_type(latitude, longitude)
 
428
  all_features['elevation_m'] = elevation
429
  all_features['month'] = prediction_time.month
430
 
431
+ logger.info("All features computed:")
432
+ for key, value in all_features.items():
433
+ logger.info(f" {key}: {value}")
434
+
435
+ logger.info("=" * 80)
436
+ logger.info("STEP 3: Extracting 13 features for transformation")
437
+ logger.info("=" * 80)
438
+
439
  transformation_features = {
440
  'count_prev_1d': all_features['count_prev_1d'],
441
  'meanmag_prev_1d': all_features['meanmag_prev_1d'],
 
452
  'dist_to_boundary_km': all_features['dist_to_boundary_km']
453
  }
454
 
455
+ logger.info("Features for transformation:")
456
+ for key, value in transformation_features.items():
457
+ logger.info(f" {key}: {value}")
458
+
459
+ logger.info("=" * 80)
460
+ logger.info("STEP 4: Computing cyclic month features")
461
+ logger.info("=" * 80)
462
+
463
  month_sin = np.sin(2 * np.pi * prediction_time.month / 12)
464
  month_cos = np.cos(2 * np.pi * prediction_time.month / 12)
465
 
466
+ logger.info(f" month: {prediction_time.month}")
467
+ logger.info(f" month_sin: {month_sin}")
468
+ logger.info(f" month_cos: {month_cos}")
469
 
470
  data_info = {
471
  'earthquakes_1d': len(earthquakes_1d),
472
  'earthquakes_7d': len(earthquakes_7d),
473
  'earthquakes_30d': len(earthquakes_30d),
474
  'earthquakes_90d': len(earthquakes_90d),
475
+ 'latest_earthquake': earthquakes_7d[0] if earthquakes_7d else None
476
  }
477
 
478
  return all_features, transformation_features, month_sin, month_cos, data_info
479
 
480
+
481
  # ============================================================================
482
  # API Endpoints
483
  # ============================================================================
484
+
485
+ @app.get("/")
486
  async def root():
487
+ """Health check endpoint"""
488
+ return {
489
+ "status": "online",
490
+ "service": "Earthquake Prediction API",
491
+ "version": "1.0.0",
492
+ "models_loaded": all([
493
+ occurrence_transformer is not None,
494
+ occurrence_model is not None,
495
+ severity_transformer is not None,
496
+ severity_model is not None
497
+ ])
498
+ }
499
+
500
 
501
  @app.post("/predict", response_model=PredictionResponse)
502
  async def predict_earthquake(request: PredictionRequest):
503
+ """
504
+ Predict earthquake occurrence and severity for a given location and time
505
+ """
506
  try:
507
+ logger.info("=" * 80)
508
+ logger.info(f"NEW PREDICTION REQUEST")
509
+ logger.info(f"Location: ({request.latitude}, {request.longitude})")
510
+ logger.info(f"Time: {request.time}")
511
+ logger.info("=" * 80)
512
+
513
+ # Parse prediction time
514
+ try:
515
+ prediction_time = datetime.fromisoformat(request.time)
516
+ except ValueError:
517
+ raise HTTPException(status_code=400,
518
+ detail="Invalid time format. Use ISO format (e.g., '2025-10-22T14:00:00')")
519
 
520
  all_features, transformation_features, month_sin, month_cos, data_info = compute_all_features(
521
+ request.latitude,
522
+ request.longitude,
523
+ prediction_time
524
  )
525
 
526
+ logger.info("=" * 80)
527
+ logger.info("STEP 5: Applying PowerTransformer to 13 features")
528
+ logger.info("=" * 80)
529
+
530
+ transformer_feature_names = occurrence_transformer.feature_names_in_
531
+ df_for_transform = pd.DataFrame([transformation_features])[transformer_feature_names]
532
+ logger.info(f"DataFrame shape: {df_for_transform.shape}")
533
+ logger.info(f"Columns: {list(df_for_transform.columns)}")
534
 
535
+ transformed_features = occurrence_transformer.transform(df_for_transform)
536
+ logger.info(f"✓ Transformation successful")
537
+ logger.info(f" Transformed shape: {transformed_features.shape}")
538
+ logger.info(f" Sample values: {transformed_features[0][:5]}")
539
+
540
+ transformed_dict = {}
541
+ for i, feature_name in enumerate(transformer_feature_names):
542
+ transformed_dict[feature_name] = transformed_features[0][i]
543
+
544
+ logger.info("=" * 80)
545
+ logger.info("STEP 6: Building 16 selected features for model")
546
+ logger.info("=" * 80)
547
 
 
548
  selected_features = {
549
+ 'meanmag_prev_1d': transformed_dict['meanmag_prev_1d'],
550
+ 'maxmag_prev_1d': transformed_dict['maxmag_prev_1d'],
551
+ 'meanmag_prev_7d': transformed_dict['meanmag_prev_7d'],
552
+ 'log_energy_prev_7d': transformed_dict['log_energy_prev_7d'],
553
  'meanmag_prev_30d': all_features['meanmag_prev_30d'],
554
  'log_energy_prev_30d': all_features['log_energy_prev_30d'],
555
  'meanmag_prev_90d': all_features['meanmag_prev_90d'],
556
  'log_energy_prev_90d': all_features['log_energy_prev_90d'],
557
+ 'days_since_last_event': transformed_dict['days_since_last_event'],
558
+ 'rate_change_7d_vs_30d': transformed_dict['rate_change_7d_vs_30d'],
559
+ 'dist_to_boundary_km': transformed_dict['dist_to_boundary_km'],
560
  'elevation_m': all_features['elevation_m'],
561
+ 'boundary_type': all_features['boundary_type'],
562
+ 'crust_type': all_features['crust_type'],
563
  'month_sin': month_sin,
564
  'month_cos': month_cos
565
  }
566
 
567
+ logger.info("Selected features (in order):")
568
+ for key, value in selected_features.items():
569
+ logger.info(f" {key}: {value}")
570
+
571
+ logger.info("=" * 80)
572
+ logger.info("STEP 7: Creating final DataFrame for model")
573
+ logger.info("=" * 80)
574
+
575
  final_df = pd.DataFrame([selected_features])
576
+ logger.info(f"Final DataFrame shape: {final_df.shape}")
577
+ logger.info(f"Final columns: {list(final_df.columns)}")
578
 
579
+ logger.info("=" * 80)
580
+ logger.info("STEP 8: Making predictions")
581
+ logger.info("=" * 80)
582
+
583
+ occurrence_pred = occurrence_model.predict(final_df)[0]
584
+ occurrence_prob = occurrence_model.predict_proba(final_df)[0]
585
+
586
+ will_occur = int(occurrence_pred) # 0 for not occurred, 1 for occurred
587
+ confidence = float(occurrence_prob[1]) # Keep as float for accuracy
588
+
589
+ logger.info(f"✓ Occurrence prediction: {will_occur}")
590
+ logger.info(f" Confidence: {confidence:.2%}")
591
+ logger.info(f" Probabilities: [No EQ: {occurrence_prob[0]:.4f}, EQ: {occurrence_prob[1]:.4f}]")
592
 
593
  severity_result = None
594
  if will_occur:
595
+ logger.info("Predicting severity...")
596
  severity_pred = severity_model.predict(final_df)[0]
597
+ severity_prob = severity_model.predict_proba(final_df)[0]
598
+ severity_class = int(severity_pred) # 0 for medium, 1 for high
599
+
600
+ severity_result = {
601
+ "severity_class": severity_class,
602
+ "confidence": round(float(severity_prob[severity_pred]), 4)
603
+ }
604
+
605
+ logger.info(f"✓ Severity: {severity_class}")
606
+ logger.info(f" Confidence: {severity_result['confidence']:.2%}")
607
+
608
+ if will_occur and severity_result:
609
+ if severity_result['severity_class'] == 1:
610
+ risk_level = "HIGH"
611
+ recommendation = "Immediate evacuation and emergency preparedness"
612
+ else:
613
+ risk_level = "MODERATE"
614
+ recommendation = "Stay alert and prepare emergency supplies"
615
  else:
616
+ risk_level = "VERY LOW"
617
+ recommendation = "No significant seismic activity expected"
618
+
619
+ logger.info(f"✓ Risk Level: {risk_level}")
620
+ logger.info("=" * 80)
621
+
622
+ response = PredictionResponse(
623
+ location={
624
+ "latitude": request.latitude,
625
+ "longitude": request.longitude,
626
+ "time": request.time
627
+ },
628
  all_features=all_features,
629
  features_for_transformation=transformation_features,
630
  selected_features=selected_features,
631
+ occurrence_prediction={
632
+ "will_occur": will_occur,
633
+ "confidence": confidence # Float value
634
+ },
635
  severity_prediction=severity_result,
636
+ risk_assessment={
637
+ "risk_level": risk_level,
638
+ "recommendation": recommendation
639
+ },
640
+ data_quality={
641
+ "earthquakes_analyzed": {
642
+ "last_1_day": data_info['earthquakes_1d'],
643
+ "last_7_days": data_info['earthquakes_7d'],
644
+ "last_30_days": data_info['earthquakes_30d'],
645
+ "last_90_days": data_info['earthquakes_90d']
646
+ },
647
+ "latest_earthquake": data_info['latest_earthquake']['place'] if data_info[
648
+ 'latest_earthquake'] else "None in past 7 days",
649
+ "data_source": "USGS Earthquake Catalog",
650
+ "boundary_type": all_features['boundary_type'],
651
+ "crust_type": all_features['crust_type'],
652
+ "elevation_m": all_features['elevation_m']
653
+ },
654
  timestamp=datetime.utcnow().isoformat()
655
  )
656
+
657
+ logger.info("✓ Prediction completed successfully!")
658
+ logger.info("=" * 80)
659
+ return response
660
+
661
  except Exception as e:
662
+ logger.error(f"Prediction error: {e}", exc_info=True)
663
  raise HTTPException(status_code=500, detail=str(e))
664
 
665
+
666
+ @app.api_route("/health", methods=["GET", "HEAD"])
667
  async def health_check():
668
+ """Detailed health check"""
669
  return {
670
  "status": "healthy",
671
+ "models": {
672
+ "occurrence_transformer": occurrence_transformer is not None,
673
+ "occurrence_model": occurrence_model is not None,
674
+ "severity_transformer": severity_transformer is not None,
675
+ "severity_model": severity_model is not None
676
+ },
677
+ "external_services": {
678
+ "usgs_api": "operational",
679
+ "elevation_api": "operational"
680
+ },
681
  "timestamp": datetime.utcnow().isoformat()
682
  }
683
 
684
+
685
  if __name__ == "__main__":
686
  import uvicorn
687
+
688
  uvicorn.run(app, host="0.0.0.0", port=7860)