jimmy60504 commited on
Commit
23eb3ea
·
1 Parent(s): 7781d84

docs: refactor event handling and streamline data loading in app.py

Browse files
app.py CHANGED
@@ -1,33 +1,21 @@
1
  import gradio as gr
2
- import numpy as np
3
  import matplotlib.pyplot as plt
4
- from obspy import read
5
- import xarray as xr
 
6
  import torch
7
- import torch.nn as nn
 
 
 
8
  from scipy.signal import detrend, iirfilter, sosfilt, zpk2sos
9
  from scipy.spatial import cKDTree
10
- import pandas as pd
11
- from loguru import logger
12
- import plotly.graph_objs as go
13
 
14
  # 設定 matplotlib 中文字體支援
15
- plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
16
- plt.rcParams['axes.unicode_minus'] = False # 解決負號顯示問題
17
-
18
- # GPU/CPU 設定
19
- if torch.cuda.is_available():
20
- device = torch.device("cuda")
21
- logger.info("使用 GPU")
22
- elif torch.mps.is_available():
23
- device = torch.device("mps")
24
- logger.info("使用 Apple MPS")
25
- else:
26
- device = torch.device("cpu")
27
- logger.info("使用 CPU")
28
-
29
- # 載入 Vs30 資料集(從 Hugging Face 下載)
30
- from huggingface_hub import hf_hub_download
31
 
32
  tree = None
33
  vs30_table = None
@@ -35,16 +23,16 @@ vs30_table = None
35
  try:
36
  logger.info("從 Hugging Face 載入 Vs30 資料...")
37
  vs30_file = hf_hub_download(
38
- repo_id="SeisBlue/TaiwanVs30",
39
- filename="Vs30ofTaiwan.nc",
40
  repo_type="dataset"
41
  )
42
  ds = xr.open_dataset(vs30_file)
43
- lat_flat = ds['lat'].values.flatten()
44
- lon_flat = ds['lon'].values.flatten()
45
- vs30_flat = ds['vs30'].values.flatten()
46
 
47
- vs30_table = pd.DataFrame({'lat': lat_flat, 'lon': lon_flat, 'Vs30': vs30_flat})
 
48
  vs30_table = vs30_table.replace([np.inf, -np.inf], np.nan).dropna()
49
  tree = cKDTree(vs30_table[["lat", "lon"]])
50
  logger.info("Vs30 資料載入完成")
@@ -61,13 +49,18 @@ try:
61
 
62
  # 驗證 site_info.csv 必要欄位
63
  required_site_fields = ["Station", "Latitude", "Longitude", "Elevation"]
64
- missing_site_fields = [f for f in required_site_fields if f not in site_info.columns]
 
 
65
  if missing_site_fields:
66
- logger.error(f"{site_info_file} 缺少必要欄位: {missing_site_fields}")
67
- raise ValueError(f"site_info.csv 缺少必要欄位: {missing_site_fields}")
 
 
68
 
69
  # 只保留唯一的測站(去除重複的分量)
70
- site_info = site_info.drop_duplicates(subset=['Station']).reset_index(drop=True)
 
71
  logger.info(f"{site_info_file} 載入完成,共 {len(site_info)} 個測站")
72
  except FileNotFoundError:
73
  logger.warning(f"{site_info_file} 找不到")
@@ -81,11 +74,15 @@ try:
81
  target_df = pd.read_csv(target_file)
82
 
83
  # 驗證 eew_target.csv 必要欄位
84
- required_target_fields = ["station", "latitude", "longitude", "elevation"]
85
- missing_target_fields = [f for f in required_target_fields if f not in target_df.columns]
 
 
 
86
  if missing_target_fields:
87
  logger.error(f"{target_file} 缺少必要欄位: {missing_target_fields}")
88
- raise ValueError(f"eew_target.csv 缺少必要欄位: {missing_target_fields}")
 
89
 
90
  target_dict = target_df.to_dict(orient="records")
91
  logger.info(f"{target_file} 載入完成(共 {len(target_dict)} 個目標點)")
@@ -94,375 +91,61 @@ except FileNotFoundError:
94
  except Exception as e:
95
  logger.error(f"{target_file} 載入失敗: {e}")
96
 
97
- # 預設地震事件
98
- EARTHQUAKE_EVENTS = {
99
- "0403花蓮地震 (2024)": "waveform/20240403.mseed",
100
- }
101
-
102
 
103
  # ============ 震央資訊管理 ============
104
 
105
- def load_earthquake_metadata(event_json_path="waveform/event.json"):
106
- """
107
- 從 JSON 檔案讀取地震事件元資料(震央經緯度、深度等)
108
-
109
- 參數:
110
- event_json_path: event.json 的相對或絕對路徑
111
-
112
- 返回:
113
- dict: {event_name -> {"epicenter_lat", "epicenter_lon", "depth_km", ...}}
114
- 若檔案缺失或無效,使用預設座標 (121.57, 23.88) 並記錄警��
115
- """
116
- earthquake_metadata = {}
117
-
118
- try:
119
- import json
120
- with open(event_json_path, 'r', encoding='utf-8') as f:
121
- data = json.load(f)
122
-
123
- if "events" not in data:
124
- logger.warning(f"{event_json_path} 缺少 'events' 鍵,使用預設座標")
125
- return {}
126
-
127
- # 將事件列表轉換為以 event_name 為鍵的字典
128
- for event in data["events"]:
129
- event_name = event.get("event_name")
130
- if event_name:
131
- earthquake_metadata[event_name] = {
132
- "epicenter_lat": event.get("epicenter_lat", 23.88),
133
- "epicenter_lon": event.get("epicenter_lon", 121.57),
134
- "depth_km": event.get("depth_km", None),
135
- "magnitude": event.get("magnitude", None),
136
- }
137
- logger.info(f"載入事件: {event_name} | 震央: ({event.get('epicenter_lon', 121.57)}, {event.get('epicenter_lat', 23.88)})")
138
-
139
- logger.info(f"地震事件元資料載入完成(共 {len(earthquake_metadata)} 個事件)")
140
- return earthquake_metadata
141
-
142
- except FileNotFoundError:
143
- logger.error(f"事件元資料檔案缺失: {event_json_path}")
144
- logger.warning("將使用預設震央座標 (121.57, 23.88)")
145
- return {}
146
-
147
- except Exception as e:
148
- logger.error(f"讀取事件元資料時發生錯誤: {e}")
149
- logger.warning("將使用預設震央座標 (121.57, 23.88)")
150
- return {}
151
-
152
-
153
- def _get_epicenter_coords(event_name):
154
- """
155
- 從全域 earthquake_metadata 獲取指定事件的震央座標
156
-
157
- 參數:
158
- event_name: 事件名稱(EARTHQUAKE_EVENTS 的鍵值)
159
 
160
- 返回:
161
- tuple: (epicenter_lat, epicenter_lon)
162
- 若事件不存在或座標缺失,返回預設座標 (23.88, 121.57)
163
- """
164
- if event_name in earthquake_metadata:
165
- metadata = earthquake_metadata[event_name]
166
- lat = metadata.get("epicenter_lat", 23.88)
167
- lon = metadata.get("epicenter_lon", 121.57)
168
- return lat, lon
169
- else:
170
- logger.warning(f"未找到事件 '{event_name}' 的元資料,使用預設震央座標")
171
- return 23.88, 121.57
172
-
173
-
174
- # 載入地震事件元資料
175
- earthquake_metadata = load_earthquake_metadata("waveform/event.json")
176
- if not earthquake_metadata:
177
- logger.warning("無法載入事件元資料,應用將使用預設震央座標")
178
-
179
-
180
- # ============ 模型定義(從 ttsam_realtime.py 複製) ============
181
-
182
- class LambdaLayer(nn.Module):
183
- def __init__(self, lambd, eps=1e-4):
184
- super(LambdaLayer, self).__init__()
185
- self.lambd = lambd
186
- self.eps = eps
187
-
188
- def forward(self, x):
189
- return self.lambd(x) + self.eps
190
-
191
-
192
- class MLP(nn.Module):
193
- def __init__(self, input_shape, dims=(500, 300, 200, 150), activation=nn.ReLU(),
194
- last_activation=None):
195
- super(MLP, self).__init__()
196
- if last_activation is None:
197
- last_activation = activation
198
- self.dims = dims
199
- self.first_fc = nn.Linear(input_shape[0], dims[0])
200
- self.first_activation = activation
201
-
202
- more_hidden = []
203
- if len(self.dims) > 2:
204
- for i in range(1, len(self.dims) - 1):
205
- more_hidden.append(nn.Linear(self.dims[i - 1], self.dims[i]))
206
- more_hidden.append(nn.ReLU())
207
-
208
- self.more_hidden = nn.ModuleList(more_hidden)
209
- self.last_fc = nn.Linear(dims[-2], dims[-1])
210
- self.last_activation = last_activation
211
-
212
- def forward(self, x):
213
- output = self.first_fc(x)
214
- output = self.first_activation(output)
215
- if self.more_hidden:
216
- for layer in self.more_hidden:
217
- output = layer(output)
218
- output = self.last_fc(output)
219
- output = self.last_activation(output)
220
- return output
221
-
222
-
223
- class CNN(nn.Module):
224
- def __init__(self, input_shape=(-1, 6000, 3), activation=nn.ReLU(), downsample=1,
225
- mlp_input=11665, mlp_dims=(500, 300, 200, 150), eps=1e-8):
226
- super(CNN, self).__init__()
227
- self.input_shape = input_shape
228
- self.activation = activation
229
- self.downsample = downsample
230
- self.mlp_input = mlp_input
231
- self.mlp_dims = mlp_dims
232
- self.eps = eps
233
-
234
- self.lambda_layer_1 = LambdaLayer(
235
- lambda t: t / (
236
- torch.max(torch.max(torch.abs(t), dim=1, keepdim=True).values,
237
- dim=2, keepdim=True).values + self.eps)
238
- )
239
- self.unsqueeze_layer1 = LambdaLayer(lambda t: torch.unsqueeze(t, dim=1))
240
- self.lambda_layer_2 = LambdaLayer(
241
- lambda t: torch.log(torch.max(torch.max(torch.abs(t), dim=1).values,
242
- dim=1).values + self.eps) / 100
243
- )
244
- self.unsqueeze_layer2 = LambdaLayer(lambda t: torch.unsqueeze(t, dim=1))
245
- self.conv2d1 = nn.Sequential(
246
- nn.Conv2d(1, 8, kernel_size=(1, downsample), stride=(1, downsample)),
247
- nn.ReLU())
248
- self.conv2d2 = nn.Sequential(
249
- nn.Conv2d(8, 32, kernel_size=(16, 3), stride=(1, 3)), nn.ReLU())
250
- self.conv1d1 = nn.Sequential(nn.Conv1d(32, 64, kernel_size=16), nn.ReLU())
251
- self.maxpooling = nn.MaxPool1d(2)
252
- self.conv1d2 = nn.Sequential(nn.Conv1d(64, 128, kernel_size=16), nn.ReLU())
253
- self.conv1d3 = nn.Sequential(nn.Conv1d(128, 32, kernel_size=8), nn.ReLU())
254
- self.conv1d4 = nn.Sequential(nn.Conv1d(32, 32, kernel_size=8), nn.ReLU())
255
- self.conv1d5 = nn.Sequential(nn.Conv1d(32, 16, kernel_size=4), nn.ReLU())
256
- self.mlp = MLP((self.mlp_input,), dims=self.mlp_dims)
257
-
258
- def forward(self, x):
259
- output = self.lambda_layer_1(x)
260
- output = self.unsqueeze_layer1(output)
261
- scale = self.lambda_layer_2(x)
262
- scale = self.unsqueeze_layer2(scale)
263
- output = self.conv2d1(output)
264
- output = self.conv2d2(output)
265
- output = torch.squeeze(output, dim=-1)
266
- output = self.conv1d1(output)
267
- output = self.maxpooling(output)
268
- output = self.conv1d2(output)
269
- output = self.maxpooling(output)
270
- output = self.conv1d3(output)
271
- output = self.maxpooling(output)
272
- output = self.conv1d4(output)
273
- output = self.conv1d5(output)
274
- output = torch.flatten(output, start_dim=1)
275
- output = torch.cat((output, scale), dim=1)
276
- output = self.mlp(output)
277
- return output
278
-
279
-
280
- class PositionEmbeddingVs30(nn.Module):
281
- def __init__(self, wavelengths=((5, 30), (110, 123), (0.01, 5000), (100, 1600)),
282
- emb_dim=500):
283
- super(PositionEmbeddingVs30, self).__init__()
284
- self.wavelengths = wavelengths
285
- self.emb_dim = emb_dim
286
-
287
- min_lat, max_lat = wavelengths[0]
288
- min_lon, max_lon = wavelengths[1]
289
- min_depth, max_depth = wavelengths[2]
290
- min_vs30, max_vs30 = wavelengths[3]
291
-
292
- assert emb_dim % 10 == 0
293
- lat_dim = emb_dim // 5
294
- lon_dim = emb_dim // 5
295
- depth_dim = emb_dim // 10
296
- vs30_dim = emb_dim // 10
297
-
298
- self.lat_coeff = 2 * np.pi * 1.0 / min_lat * (
299
- (min_lat / max_lat) ** (np.arange(lat_dim) / lat_dim))
300
- self.lon_coeff = 2 * np.pi * 1.0 / min_lon * (
301
- (min_lon / max_lon) ** (np.arange(lon_dim) / lon_dim))
302
- self.depth_coeff = 2 * np.pi * 1.0 / min_depth * (
303
- (min_depth / max_depth) ** (np.arange(depth_dim) / depth_dim))
304
- self.vs30_coeff = 2 * np.pi * 1.0 / min_vs30 * (
305
- (min_vs30 / max_vs30) ** (np.arange(vs30_dim) / vs30_dim))
306
-
307
- lat_sin_mask = np.arange(emb_dim) % 5 == 0
308
- lat_cos_mask = np.arange(emb_dim) % 5 == 1
309
- lon_sin_mask = np.arange(emb_dim) % 5 == 2
310
- lon_cos_mask = np.arange(emb_dim) % 5 == 3
311
- depth_sin_mask = np.arange(emb_dim) % 10 == 4
312
- depth_cos_mask = np.arange(emb_dim) % 10 == 9
313
- vs30_sin_mask = np.arange(emb_dim) % 10 == 5
314
- vs30_cos_mask = np.arange(emb_dim) % 10 == 8
315
-
316
- self.mask = np.zeros(emb_dim)
317
- self.mask[lat_sin_mask] = np.arange(lat_dim)
318
- self.mask[lat_cos_mask] = lat_dim + np.arange(lat_dim)
319
- self.mask[lon_sin_mask] = 2 * lat_dim + np.arange(lon_dim)
320
- self.mask[lon_cos_mask] = 2 * lat_dim + lon_dim + np.arange(lon_dim)
321
- self.mask[depth_sin_mask] = 2 * lat_dim + 2 * lon_dim + np.arange(depth_dim)
322
- self.mask[depth_cos_mask] = 2 * lat_dim + 2 * lon_dim + depth_dim + np.arange(
323
- depth_dim)
324
- self.mask[
325
- vs30_sin_mask] = 2 * lat_dim + 2 * lon_dim + 2 * depth_dim + np.arange(
326
- vs30_dim)
327
- self.mask[
328
- vs30_cos_mask] = 2 * lat_dim + 2 * lon_dim + 2 * depth_dim + vs30_dim + np.arange(
329
- vs30_dim)
330
- self.mask = self.mask.astype("int32")
331
-
332
- def forward(self, x):
333
- lat_base = x[:, :, 0:1].to(device) * torch.Tensor(self.lat_coeff).to(device)
334
- lon_base = x[:, :, 1:2].to(device) * torch.Tensor(self.lon_coeff).to(device)
335
- depth_base = x[:, :, 2:3].to(device) * torch.Tensor(self.depth_coeff).to(device)
336
- vs30_base = x[:, :, 3:4] * torch.Tensor(self.vs30_coeff).to(device)
337
-
338
- output = torch.cat([
339
- torch.sin(lat_base), torch.cos(lat_base),
340
- torch.sin(lon_base), torch.cos(lon_base),
341
- torch.sin(depth_base), torch.cos(depth_base),
342
- torch.sin(vs30_base), torch.cos(vs30_base),
343
- ], dim=-1)
344
-
345
- maskk = torch.from_numpy(np.array(self.mask)).long()
346
- index = (maskk.unsqueeze(0).unsqueeze(0)).expand(x.shape[0], 1,
347
- self.emb_dim).to(device)
348
- output = torch.gather(output, -1, index).to(device)
349
- return output
350
-
351
-
352
- class TransformerEncoder(nn.Module):
353
- def __init__(self, d_model=150, nhead=10, batch_first=True, activation="gelu",
354
- dropout=0.0, dim_feedforward=1000):
355
- super(TransformerEncoder, self).__init__()
356
- self.encoder_layer = nn.TransformerEncoderLayer(
357
- d_model=d_model, nhead=nhead, batch_first=batch_first,
358
- activation=activation, dropout=dropout, dim_feedforward=dim_feedforward
359
- ).to(device)
360
- self.transformer_encoder = nn.TransformerEncoder(self.encoder_layer, 6).to(
361
- device)
362
-
363
- def forward(self, x, src_key_padding_mask=None):
364
- return self.transformer_encoder(x, src_key_padding_mask=src_key_padding_mask)
365
-
366
-
367
- class MDN(nn.Module):
368
- def __init__(self, input_shape=(150,), n_hidden=20, n_gaussians=5):
369
- super(MDN, self).__init__()
370
- self.z_h = nn.Sequential(nn.Linear(input_shape[0], n_hidden), nn.Tanh())
371
- self.z_weight = nn.Linear(n_hidden, n_gaussians)
372
- self.z_sigma = nn.Linear(n_hidden, n_gaussians)
373
- self.z_mu = nn.Linear(n_hidden, n_gaussians)
374
-
375
- def forward(self, x):
376
- z_h = self.z_h(x)
377
- weight = nn.functional.softmax(self.z_weight(z_h), -1)
378
- sigma = torch.exp(self.z_sigma(z_h))
379
- mu = self.z_mu(z_h)
380
- return weight, sigma, mu
381
-
382
-
383
- class FullModel(nn.Module):
384
- def __init__(self, model_cnn, model_position, model_transformer, model_mlp,
385
- model_mdn,
386
- max_station=25, pga_targets=15, emb_dim=150, data_length=6000):
387
- super(FullModel, self).__init__()
388
- self.data_length = data_length
389
- self.model_CNN = model_cnn
390
- self.model_Position = model_position
391
- self.model_Transformer = model_transformer
392
- self.model_mlp = model_mlp
393
- self.model_MDN = model_mdn
394
- self.max_station = max_station
395
- self.pga_targets = pga_targets
396
- self.emb_dim = emb_dim
397
-
398
- def forward(self, data):
399
- cnn_output = self.model_CNN(
400
- torch.DoubleTensor(
401
- data["waveform"].reshape(-1, self.data_length, 3)).float().to(device)
402
- )
403
- cnn_output_reshape = torch.reshape(cnn_output,
404
- (-1, self.max_station, self.emb_dim))
405
-
406
- emb_output = self.model_Position(
407
- torch.DoubleTensor(
408
- data["station"].reshape(-1, 1, data["station"].shape[2])).float().to(
409
- device)
410
- )
411
- emb_output = emb_output.reshape(-1, self.max_station, self.emb_dim)
412
-
413
- station_pad_mask = data["station"] == 0
414
- station_pad_mask = torch.all(station_pad_mask, 2)
415
-
416
- pga_pos_emb_output = self.model_Position(
417
- torch.DoubleTensor(
418
- data["target"].reshape(-1, 1, data["target"].shape[2])).float().to(
419
- device)
420
- )
421
- pga_pos_emb_output = pga_pos_emb_output.reshape(-1, self.pga_targets,
422
- self.emb_dim)
423
-
424
- target_pad_mask = torch.ones_like(data["target"], dtype=torch.bool)
425
- target_pad_mask = torch.all(target_pad_mask, 2)
426
- pad_mask = torch.cat((station_pad_mask, target_pad_mask), dim=1).to(device)
427
-
428
- add_pe_cnn_output = torch.add(cnn_output_reshape, emb_output)
429
- transformer_input = torch.cat((add_pe_cnn_output, pga_pos_emb_output), dim=1)
430
- transformer_output = self.model_Transformer(transformer_input, pad_mask)
431
 
432
- mlp_input = transformer_output[:, -self.pga_targets:, :].to(device)
433
- mlp_output = self.model_mlp(mlp_input)
434
- weight, sigma, mu = self.model_MDN(mlp_output)
435
 
436
- return weight, sigma, mu
 
437
 
438
 
439
- def get_full_model(model_path):
440
- emb_dim = 150
441
- mlp_dims = (150, 100, 50, 30, 10)
442
- cnn_model = CNN(mlp_input=5665).to(device)
443
- pos_emb_model = PositionEmbeddingVs30(emb_dim=emb_dim).to(device)
444
- transformer_model = TransformerEncoder()
445
- mlp_model = MLP(input_shape=(emb_dim,), dims=mlp_dims).to(device)
446
- mdn_model = MDN(input_shape=(mlp_dims[-1],)).to(device)
447
- full_model = FullModel(
448
- cnn_model, pos_emb_model, transformer_model, mlp_model, mdn_model,
449
- pga_targets=25, data_length=3000
450
- ).to(device)
451
- full_model.load_state_dict(
452
- torch.load(model_path, weights_only=True, map_location=device))
453
- return full_model
454
 
455
 
456
  # 載入模型
457
  model_path = hf_hub_download(
458
- repo_id="SeisBlue/TTSAM",
459
- filename="ttsam_trained_model_11.pt"
460
  )
461
  model = get_full_model(model_path)
462
 
463
 
464
  # ============ 輔助函數 ============
465
 
 
466
  def lowpass(data, freq=10, df=100, corners=4):
467
  fe = 0.5 * df
468
  f = freq / fe
@@ -490,7 +173,6 @@ def get_vs30(lat, lon, user_vs30=600):
490
  return float(vs30)
491
 
492
 
493
-
494
  def calculate_intensity(pga, label=False):
495
  intensity_label = ["0", "1", "2", "3", "4", "5-", "5+", "6-", "6+", "7"]
496
  pga_level = np.log10([1e-5, 0.008, 0.025, 0.080, 0.250, 0.80, 1.4, 2.5, 4.4, 8.0])
@@ -506,16 +188,10 @@ def calculate_intensity(pga, label=False):
506
 
507
  # ============ Gradio 介面函數 ============
508
 
509
- def load_waveform(event_name):
510
- """載入完整的 mseed 檔案(包含所有測站)"""
511
- file_path = EARTHQUAKE_EVENTS[event_name]
512
- st = read(file_path)
513
- return st
514
-
515
 
516
  def calculate_distance(lat1, lon1, lat2, lon2):
517
  """計算兩點間的距離(簡化的平面距離,單位:度)"""
518
- return np.sqrt((lat1 - lat2)**2 + (lon1 - lon2)**2)
519
 
520
 
521
  def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
@@ -542,9 +218,13 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
542
 
543
  # 驗證必要欄位存在
544
  required_fields = ["Latitude", "Longitude", "Elevation"]
545
- missing_fields = [f for f in required_fields if f not in station_data.columns]
 
 
546
  if missing_fields:
547
- logger.warning(f"測站 {station_code} 缺少必要欄位: {missing_fields},跳過")
 
 
548
  continue
549
 
550
  lat = station_data["Latitude"].values[0]
@@ -557,7 +237,7 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
557
  "distance": distance,
558
  "latitude": lat,
559
  "longitude": lon,
560
- "elevation": elev
561
  }
562
  except Exception as e:
563
  logger.warning(f"測站 {station_code} 資訊查詢失敗: {e}")
@@ -571,14 +251,20 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
571
  # 記錄實際可用的測站數(少於 25 站也允許繼續)
572
  actual_count = len(selected_stations)
573
  if actual_count < n_stations:
574
- logger.warning(f"僅找到 {actual_count} 個可用測站(目標 {n_stations} 個),將繼續處理")
 
 
575
  else:
576
- logger.info(f"從 {len(station_list)} 個輸入測站中選擇了最近的 {actual_count} 個")
 
 
577
 
578
  return selected_stations
579
 
580
 
581
- def extract_waveforms_from_stream(st, selected_stations, start_time, duration, vs30_input):
 
 
582
  """
583
  從 Stream 中提取選定測站的波形資料
584
 
@@ -608,18 +294,21 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
608
  sampling_rate = 100 # 100 Hz
609
  min_duration = 30.0 # 最小時間長度 30 秒
610
  target_length = 3000 # 30 秒 @ 100 Hz = 3000 samples
 
611
 
612
  # 內部計算 end_time(接受 start/duration 參數)
613
- end_time = start_time + duration
614
 
615
- start_idx = int(start_time * sampling_rate)
616
  end_idx = int(end_time * sampling_rate)
617
  actual_samples = end_idx - start_idx
618
 
619
  # 檢查是否需要零填充:長度不足 30 秒時尾段以 0 遮罩補齊
620
  needs_padding = duration < min_duration
621
  if needs_padding:
622
- logger.info(f"時間長度 {duration} 秒 < 30 秒,將以 0 遮罩補齊至 {min_duration} 秒")
 
 
623
 
624
  for station_data in selected_stations:
625
  station_code = station_data["station"]
@@ -634,8 +323,12 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
634
 
635
  # 嘗試取得 Z, N, E 分量
636
  z_trace = st_station.select(component="Z")
637
- n_trace = st_station.select(component="N") or st_station.select(component="1")
638
- e_trace = st_station.select(component="E") or st_station.select(component="2")
 
 
 
 
639
 
640
  # 檢查 Z 分量(必須存在)
641
  if len(z_trace) > 0:
@@ -684,13 +377,17 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
684
  waveforms.append(waveform_3c)
685
 
686
  # 準備測站資訊
687
- vs30 = get_vs30(station_data["latitude"], station_data["longitude"], vs30_input)
688
- station_info_list.append([
689
- station_data["latitude"],
690
- station_data["longitude"],
691
- station_data["elevation"],
692
- vs30
693
- ])
 
 
 
 
694
  valid_stations.append(station_data)
695
 
696
  except Exception as e:
@@ -699,10 +396,13 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
699
 
700
  logger.info(f"成功提取 {len(waveforms)} 個測站的波形")
701
  if missing_components_count > 0:
702
- logger.info(f"其中 {missing_components_count} 個測站缺少 N 或 E 分量(已以 Z 分量代替)")
 
 
703
 
704
  return waveforms, station_info_list, valid_stations, missing_components_count
705
 
 
706
  def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
707
  """創建輸入測站分布地圖:顯示所有測站 + 突顯被選中的 25 個(使用 Plotly)"""
708
 
@@ -722,9 +422,21 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
722
  all_stations_text.append(station_code)
723
 
724
  # 準備選中測站資料(按距離分組)
725
- selected_group1_lat, selected_group1_lon, selected_group1_text = [], [], [] # 前 5 近
726
- selected_group2_lat, selected_group2_lon, selected_group2_text = [], [], [] # 6-15 近
727
- selected_group3_lat, selected_group3_lon, selected_group3_text = [], [], [] # 16-25 近
 
 
 
 
 
 
 
 
 
 
 
 
728
 
729
  for i, station_data in enumerate(selected_stations):
730
  station_code = station_data["station"]
@@ -751,82 +463,94 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
751
  fig = go.Figure()
752
 
753
  # 添加所有測站(灰色小點)
754
- fig.add_trace(go.Scattermap(
755
- lat=all_stations_lat,
756
- lon=all_stations_lon,
757
- mode='markers',
758
- marker=dict(size=6, color='gray', opacity=0.6),
759
- text=all_stations_text,
760
- hovertemplate='%{text}<extra></extra>',
761
- name=f'所有測站 ({len(all_stations_lat)} 個)',
762
- showlegend=True
763
- ))
 
 
764
 
765
  # 添加選中測站 - 前 5 近(綠色)
766
  if selected_group1_lat:
767
- fig.add_trace(go.Scattermap(
768
- lat=selected_group1_lat,
769
- lon=selected_group1_lon,
770
- mode='markers',
771
- marker=dict(size=12, color='green', opacity=0.8),
772
- text=selected_group1_text,
773
- hovertemplate='%{text}<extra></extra>',
774
- name='前 5 近',
775
- showlegend=True
776
- ))
 
 
777
 
778
  # 添加選中測站 - 6-15 近(藍色)
779
  if selected_group2_lat:
780
- fig.add_trace(go.Scattermap(
781
- lat=selected_group2_lat,
782
- lon=selected_group2_lon,
783
- mode='markers',
784
- marker=dict(size=12, color='blue', opacity=0.8),
785
- text=selected_group2_text,
786
- hovertemplate='%{text}<extra></extra>',
787
- name='6-15 近',
788
- showlegend=True
789
- ))
 
 
790
 
791
  # 添加選中測站 - 16-25 近(橘色)
792
  if selected_group3_lat:
793
- fig.add_trace(go.Scattermap(
794
- lat=selected_group3_lat,
795
- lon=selected_group3_lon,
796
- mode='markers',
797
- marker=dict(size=12, color='orange', opacity=0.8),
798
- text=selected_group3_text,
799
- hovertemplate='%{text}<extra></extra>',
800
- name='16-25 近',
801
- showlegend=True
802
- ))
 
 
803
 
804
  # 添加震央(紅色大點)
805
- fig.add_trace(go.Scattermap(
806
- lat=[epicenter_lat],
807
- lon=[epicenter_lon],
808
- mode='markers',
809
- marker=dict(size=25, color='red'),
810
- text=[f'震央<br>({epicenter_lat:.3f}, {epicenter_lon:.3f})'],
811
- hovertemplate='%{text}<extra></extra>',
812
- name='震央',
813
- showlegend=True
814
- ))
815
-
816
- fig.add_trace(go.Scattermap(
817
- lat=[epicenter_lat],
818
- lon=[epicenter_lon],
819
- mode='markers',
820
- marker=dict(size=10, color='white'),
821
- showlegend=False
822
- ))
 
 
 
 
823
 
824
  # 設置地圖佈局
825
  fig.update_layout(
826
  map=dict(
827
  style="open-street-map",
828
  center=dict(lat=epicenter_lat, lon=epicenter_lon),
829
- zoom=7
830
  ),
831
  height=500, # 設置固定高度以適應 Gradio 容器
832
  margin=dict(l=0, r=0, t=0, b=0),
@@ -836,14 +560,14 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
836
  y=0.95,
837
  xanchor="left",
838
  x=0.01,
839
- bgcolor="rgba(255, 255, 255, 0.8)"
840
- )
841
  )
842
 
843
  return fig
844
 
845
 
846
- def plot_waveform(st, selected_stations, start_time, duration):
847
  """
848
  繪製選定測站的波形圖(距離-時間圖,可顯示全部 25 個測站)
849
 
@@ -854,7 +578,7 @@ def plot_waveform(st, selected_stations, start_time, duration):
854
  - duration: 時間長度(秒)
855
  """
856
  # 計算結束時間
857
- end_time = start_time + duration
858
 
859
  fig, ax = plt.subplots(figsize=(14, 10))
860
 
@@ -876,12 +600,22 @@ def plot_waveform(st, selected_stations, start_time, duration):
876
  times = tr.times()
877
  data = tr.data
878
 
 
 
 
 
 
879
  # 正規化波形振幅
880
  data_normalized = data / (np.max(np.abs(data)) + 1e-10)
881
 
882
  # 繪製波形,Y軸位置為距離
883
- ax.plot(times, distance + data_normalized * amplitude_scale,
884
- 'black', linewidth=0.3, alpha=0.8)
 
 
 
 
 
885
 
886
  distances.append(distance)
887
  station_names.append(station_code)
@@ -890,23 +624,34 @@ def plot_waveform(st, selected_stations, start_time, duration):
890
  except Exception as e:
891
  logger.warning(f"無法繪製測站 {station_code}: {e}")
892
 
 
 
893
  # 標記選取時間範圍
894
- ax.axvline(start_time, color='red', linestyle='--', linewidth=2,
895
- alpha=0.7, label='選取範圍')
896
- ax.axvline(end_time, color='red', linestyle='--', linewidth=2, alpha=0.7)
897
- ax.axvspan(start_time, end_time, alpha=0.15, color='blue')
 
 
 
 
 
 
898
 
899
  # 設定軸標籤和標題
900
- ax.set_xlabel('Time (s)', fontsize=12)
901
- ax.set_ylabel('Distance from Epicenter (°)', fontsize=12)
902
- ax.set_title(f'Record Section - {plotted_count} Stations Sorted by Distance',
903
- fontsize=14, fontweight='bold')
 
 
 
904
 
905
  # 在右側標註測站名稱
906
  if distances:
907
  ax2 = ax.twinx()
908
  ax2.set_ylim(ax.get_ylim())
909
- ax2.set_ylabel('Station Code', fontsize=12)
910
 
911
  # 每隔幾個測站標註一次(避免過於擁擠)
912
  step = max(1, len(distances) // 10)
@@ -915,8 +660,8 @@ def plot_waveform(st, selected_stations, start_time, duration):
915
  ax2.set_yticks(tick_positions)
916
  ax2.set_yticklabels(tick_labels, fontsize=8)
917
 
918
- ax.grid(True, alpha=0.3, axis='x')
919
- ax.legend(loc='upper right')
920
  plt.tight_layout()
921
 
922
  return fig
@@ -939,31 +684,54 @@ def get_intensity_color(intensity):
939
  return color_map.get(intensity, "#ffffff")
940
 
941
 
942
- def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_lon=None):
 
 
943
  """使用 Plotly 創建互動式震度分布地圖"""
944
 
945
  # 按震度等級分組資料
946
- intensity_groups = {i: {'lat': [], 'lon': [], 'text': [], 'color': get_intensity_color(i)}
947
- for i in range(10)}
 
 
948
 
949
  # 添加震度測站標記
 
 
950
  for i, target_name in enumerate(target_names):
951
  target = next((t for t in target_dict if t["station"] == target_name), None)
952
  if target:
953
  lat = target["latitude"]
954
  lon = target["longitude"]
 
 
955
  intensity = calculate_intensity(pga_list[i])
956
  intensity_label = calculate_intensity(pga_list[i], label=True)
957
  pga = pga_list[i]
958
 
959
- hover_text = (f"{target_name}<br>"
960
- f"震度: {intensity_label}<br>"
961
- f"PGA: {pga:.4f} m/s²<br>"
962
- f"位置: ({lat:.3f}, {lon:.3f})")
 
 
963
 
964
- intensity_groups[intensity]['lat'].append(lat)
965
- intensity_groups[intensity]['lon'].append(lon)
966
- intensity_groups[intensity]['text'].append(hover_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
967
 
968
  # 創建 Plotly 地圖
969
  fig = go.Figure()
@@ -972,44 +740,39 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
972
  intensity_labels = ["0", "1", "2", "3", "4", "5-", "5+", "6-", "6+", "7"]
973
  for intensity_level in range(10):
974
  group = intensity_groups[intensity_level]
975
- if group['lat']: # 有資料的震度等級
976
- fig.add_trace(go.Scattermap(
977
- lat=group['lat'],
978
- lon=group['lon'],
979
- mode='markers',
980
- marker=dict(
981
- size=14,
982
- color=group['color'],
983
- opacity=0.8
984
- ),
985
- text=group['text'],
986
- hovertemplate='%{text}<extra></extra>',
987
- name=f'震度 {intensity_labels[intensity_level]}',
988
- showlegend=True
989
- ))
990
  else:
991
  # 沒有資料的震度等級:添加隱形標記只為了顯示圖例
992
- fig.add_trace(go.Scattermap(
993
- lat=[None],
994
- lon=[None],
995
- mode='markers',
996
- marker=dict(
997
- size=14,
998
- color=group['color'],
999
- opacity=0.8
1000
- ),
1001
- name=f'震度 {intensity_labels[intensity_level]}',
1002
- showlegend=True,
1003
- hoverinfo='skip'
1004
- ))
1005
 
1006
  # 設置地圖佈局
1007
  fig.update_layout(
1008
  map=dict(
1009
  style="open-street-map",
1010
- center=dict(lat=23.5,
1011
- lon=121),
1012
- zoom=7
1013
  ),
1014
  height=800, # 設置固定高度以適應 Gradio 容器
1015
  margin=dict(l=0, r=0, t=0, b=0),
@@ -1019,14 +782,13 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
1019
  y=0.95,
1020
  xanchor="left",
1021
  x=0.01,
1022
- bgcolor="rgba(255, 255, 255, 0.8)"
1023
- )
1024
  )
1025
 
1026
  return fig
1027
 
1028
 
1029
-
1030
  def load_observed_intensity_image(event_name):
1031
  """
1032
  從 intensity_map 資料夾載入對應的實際觀測震度圖
@@ -1035,23 +797,16 @@ def load_observed_intensity_image(event_name):
1035
  """
1036
  import os
1037
 
1038
- event_file = EARTHQUAKE_EVENTS[event_name]
1039
- event_date = os.path.basename(event_file).replace('.mseed', '')
1040
-
1041
- intensity_map_dir = "intensity_map"
1042
- possible_extensions = ['.png', '.jpg', '.jpeg', '.gif']
1043
 
1044
- for ext in possible_extensions:
1045
- image_path = os.path.join(intensity_map_dir, f"{event_date}{ext}")
1046
- if os.path.exists(image_path):
1047
- logger.info(f"載入實際觀測震度圖: {image_path}")
1048
- return image_path
1049
-
1050
- logger.warning(f"找不到實際震度圖: {event_date}(將顯示空白占位)")
1051
  return None
1052
 
1053
 
1054
- def load_and_display_waveform(event_name, start_time, duration):
1055
  """載入並顯示波形,讓使用者確認範圍
1056
 
1057
  從全域 earthquake_metadata 讀取震央座標
@@ -1060,28 +815,39 @@ def load_and_display_waveform(event_name, start_time, duration):
1060
  """
1061
  try:
1062
  # 從全域 earthquake_metadata 讀取震央座標
1063
- epicenter_lat, epicenter_lon = _get_epicenter_coords(event_name)
 
 
 
1064
 
1065
  # 計算結束時間(用於顯示資訊)
1066
- end_time = start_time + duration
1067
 
1068
  # 1. 載入完整的 mseed 檔案
1069
  logger.info(f"載入地震事件: {event_name}")
1070
- st = load_waveform(event_name)
1071
  logger.info(f"��入了 {len(st)} 個 trace")
1072
 
1073
  # 2. 根據震央距離選擇最近的 25 個測站
1074
  logger.info(f"選擇距離震央 ({epicenter_lat}, {epicenter_lon}) 最近的測站...")
1075
- selected_stations = select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25)
 
 
1076
 
1077
  if len(selected_stations) == 0:
1078
- return None, None, "錯誤:找不到有效的測站資料", gr.update(interactive=False)
 
 
 
 
 
1079
 
1080
- # 3. 繪製波形(傳入 start_time duration
1081
- waveform_plot = plot_waveform(st, selected_stations, start_time, duration)
1082
 
1083
  # 4. 創建輸入測站地圖
1084
- station_map = create_input_station_map(selected_stations, epicenter_lat, epicenter_lon)
 
 
1085
 
1086
  logger.info("波形載入完成")
1087
  return station_map, waveform_plot, gr.update(interactive=True)
@@ -1089,11 +855,12 @@ def load_and_display_waveform(event_name, start_time, duration):
1089
  except Exception as e:
1090
  logger.error(f"波形載入發生錯誤: {e}")
1091
  import traceback
 
1092
  traceback.print_exc()
1093
  return None, None, gr.update(interactive=False)
1094
 
1095
 
1096
- def predict_intensity(event_name, start_time, duration):
1097
  """
1098
  執行震度預測
1099
 
@@ -1105,27 +872,33 @@ def predict_intensity(event_name, start_time, duration):
1105
  """
1106
  try:
1107
  # 從全域 earthquake_metadata 讀取震央座標
1108
- epicenter_lat, epicenter_lon = _get_epicenter_coords(event_name)
 
 
1109
 
1110
- # 計算結束時間(內部處理)
1111
- end_time = start_time + duration
1112
 
1113
  # 1. 載入完整的 mseed 檔案
1114
  logger.info(f"載入地震事件: {event_name}")
1115
- st = load_waveform(event_name)
1116
  logger.info(f"載入了 {len(st)} 個 trace")
1117
 
1118
  # 2. 根據震央距離選擇最近的 25 個測站
1119
  logger.info(f"選擇距離震央 ({epicenter_lat}, {epicenter_lon}) 最近的測站...")
1120
- selected_stations = select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25)
 
 
1121
 
1122
  if len(selected_stations) == 0:
1123
  return None, None, "錯誤:找不到有效的測站資料"
1124
 
1125
  # 3. 從選定的測站提取波形(vs30_input 使用預設值 600,會被資料庫值覆蓋)
1126
- logger.info(f"提取波形資料(時間範圍: {start_time} 秒起,持續 {duration} 秒)...")
1127
- waveforms, station_info_list, valid_stations, missing_components_count = extract_waveforms_from_stream(
1128
- st, selected_stations, start_time, duration, vs30_input=600
 
 
 
 
1129
  )
1130
 
1131
  if len(waveforms) == 0:
@@ -1149,25 +922,33 @@ def predict_intensity(event_name, start_time, duration):
1149
  total_targets = len(target_dict)
1150
  num_batches = (total_targets + batch_size - 1) // batch_size
1151
 
1152
- logger.info(f"開始分批預測 {total_targets} 個目標測站(共 {num_batches} 批)...")
 
 
1153
 
1154
  for batch_idx in range(num_batches):
1155
  start_idx = batch_idx * batch_size
1156
  end_idx = min((batch_idx + 1) * batch_size, total_targets)
1157
  batch_targets = target_dict[start_idx:end_idx]
1158
 
1159
- logger.info(f"預測第 {batch_idx + 1}/{num_batches} 批(測站 {start_idx + 1}-{end_idx})...")
 
 
1160
 
1161
  # 準備這批目標測站資訊
1162
  target_list = []
1163
  target_names = []
1164
  for target in batch_targets:
1165
- target_list.append([
1166
- target["latitude"],
1167
- target["longitude"],
1168
- target["elevation"],
1169
- get_vs30(target["latitude"], target["longitude"], user_vs30=600)
1170
- ])
 
 
 
 
1171
  target_names.append(target["station"])
1172
 
1173
  # Padding 到 25 個(如果不足 25 個)
@@ -1185,10 +966,17 @@ def predict_intensity(event_name, start_time, duration):
1185
  # 7. 執行預測
1186
  with torch.no_grad():
1187
  weight, sigma, mu = model(tensor_data)
1188
- batch_pga = torch.sum(weight * mu, dim=2).cpu().detach().numpy().flatten().tolist()
 
 
 
 
 
 
 
1189
 
1190
  # 只取實際有資料的部分
1191
- all_pga_list.extend(batch_pga[:len(target_names)])
1192
  all_target_names.extend(target_names)
1193
 
1194
  logger.info(f"完成所有 {len(all_target_names)} 個測站的預測!")
@@ -1196,7 +984,9 @@ def predict_intensity(event_name, start_time, duration):
1196
  target_names = all_target_names
1197
 
1198
  # 繪製互動式地圖(固定高度 800)
1199
- intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
 
 
1200
 
1201
  # 載入實際觀測震度圖(filepath;左側以 800 高顯示)
1202
  observed_intensity_path = load_observed_intensity_image(event_name)
@@ -1207,11 +997,12 @@ def predict_intensity(event_name, start_time, duration):
1207
  except Exception as e:
1208
  logger.error(f"預測過程發生錯誤: {e}")
1209
  import traceback
 
1210
  traceback.print_exc()
1211
  return None, None
1212
 
1213
 
1214
- def on_full_workflow(event_name, start_time, duration):
1215
  """
1216
  執行完整的工作流:波形載入 → 測站選擇 → 推論 → 結果展示
1217
 
@@ -1229,7 +1020,7 @@ def on_full_workflow(event_name, start_time, duration):
1229
  # 步驟 1: 載入波形
1230
  logger.info(f"[on_full_workflow] 步驟 1/3: 波形載入...")
1231
  station_map, waveform_plot, _ = load_and_display_waveform(
1232
- event_name, start_time, duration
1233
  )
1234
 
1235
  if station_map is None:
@@ -1239,7 +1030,7 @@ def on_full_workflow(event_name, start_time, duration):
1239
  # 步驟 2: 執行推論
1240
  logger.info(f"[on_full_workflow] 步驟 2/3: 模型推論...")
1241
  observed_img, predicted_map = predict_intensity(
1242
- event_name, start_time, duration
1243
  )
1244
 
1245
  if predicted_map is None:
@@ -1253,12 +1044,11 @@ def on_full_workflow(event_name, start_time, duration):
1253
  except Exception as e:
1254
  logger.error(f"[on_full_workflow] 完整工作流發生錯誤: {e}")
1255
  import traceback
 
1256
  traceback.print_exc()
1257
  return None, None, f"錯誤: {str(e)}", None, f"錯誤: {str(e)}", None
1258
 
1259
-
1260
  # ============ Gradio 介面 ============
1261
-
1262
  with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1263
  gr.Markdown("# 🌏 TTSAM 震度預測系統")
1264
 
@@ -1267,19 +1057,21 @@ with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1267
  # 左上:使用步驟與狀態顯示
1268
  with gr.Column(scale=1):
1269
  gr.Markdown("## 使用步驟")
1270
- gr.Markdown("""
 
1271
  1. 選擇地震事件和時間範圍
1272
  2. 輸入震央位置和場址參數
1273
  3. 點擊載入波形確認波形範圍
1274
  4. 確認無誤後點擊執行預測
1275
 
1276
  系統會自動選擇距離震央最近的 25 個測站
1277
- """)
 
1278
  with gr.Column(scale=1):
1279
  event_dropdown = gr.Dropdown(
1280
- choices=list(EARTHQUAKE_EVENTS.keys()),
1281
- value=list(EARTHQUAKE_EVENTS.keys())[0],
1282
- label="選擇地震事件"
1283
  )
1284
  # ========== 中層:輸入測站地圖與波形圖 ==========
1285
  with gr.Row():
@@ -1288,8 +1080,9 @@ with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1288
  gr.Markdown("## 輸入測站分布")
1289
  input_station_map = gr.Plot(label="輸入測站地圖")
1290
  load_waveform_btn = gr.Button("📊 載入波形", variant="secondary", scale=1)
1291
- predict_btn = gr.Button("🔮 執行預測", variant="primary", scale=1,
1292
- interactive=False)
 
1293
 
1294
  # 中右:輸入波形
1295
  with gr.Column(scale=1):
@@ -1298,9 +1091,9 @@ with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1298
  label="地震波形(選定的 25 個測站)",
1299
  )
1300
  with gr.Row():
1301
- start_slider = gr.Slider(0, 300, value=0, step=1, label="開始時間 (秒)")
1302
- duration_slider = gr.Slider(0, 30, value=30, step=1, label="時間長度 (秒)")
1303
-
1304
 
1305
  # ========== 下層:實際觀測 vs 預測結果 ==========
1306
  with gr.Row():
@@ -1309,7 +1102,6 @@ with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1309
  gr.Markdown("## 預測震度分布")
1310
  predicted_intensity_map = gr.Plot(label="互動式震度地圖")
1311
 
1312
-
1313
  # 右下:實際觀測震度圖
1314
  with gr.Column(scale=1):
1315
  gr.Markdown("## 實際觀測震度分布")
@@ -1317,37 +1109,47 @@ with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1317
  label="實際觀測震度",
1318
  type="filepath",
1319
  height=800,
1320
- value=load_observed_intensity_image(list(EARTHQUAKE_EVENTS.keys())[0])
1321
  )
1322
 
1323
  # 綁定事件
1324
  event_dropdown.change(
1325
- fn=lambda event_name, start_time, duration: (
1326
- *on_full_workflow(event_name, start_time, duration),
1327
  ),
1328
- inputs=[event_dropdown, start_slider, duration_slider],
1329
- outputs=[input_station_map, waveform_plot, predicted_intensity_map, observed_intensity_image]
 
 
 
 
 
1330
  )
1331
 
1332
  load_waveform_btn.click(
1333
  fn=load_and_display_waveform,
1334
- inputs=[event_dropdown, start_slider, duration_slider],
1335
- outputs=[input_station_map, waveform_plot, predict_btn]
1336
  )
1337
 
1338
  predict_btn.click(
1339
  fn=predict_intensity,
1340
- inputs=[event_dropdown, start_slider, duration_slider],
1341
- outputs=[observed_intensity_image, predicted_intensity_map]
1342
  )
1343
 
1344
  # 應用啟動時自動執行完整工作流
1345
  demo.load(
1346
- fn=lambda event_name, start_time, duration: (
1347
- *on_full_workflow(event_name, start_time, duration),
1348
  ),
1349
- inputs=[event_dropdown, start_slider, duration_slider],
1350
- outputs=[input_station_map, waveform_plot, predicted_intensity_map, observed_intensity_image]
 
 
 
 
 
1351
  )
1352
 
1353
  demo.launch()
 
1
  import gradio as gr
 
2
  import matplotlib.pyplot as plt
3
+ import numpy as np
4
+ import pandas as pd
5
+ import plotly.graph_objs as go
6
  import torch
7
+ import xarray as xr
8
+ from huggingface_hub import hf_hub_download
9
+ from loguru import logger
10
+ from obspy import read
11
  from scipy.signal import detrend, iirfilter, sosfilt, zpk2sos
12
  from scipy.spatial import cKDTree
13
+
14
+ from model import get_full_model
 
15
 
16
  # 設定 matplotlib 中文字體支援
17
+ plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "DejaVu Sans"]
18
+ plt.rcParams["axes.unicode_minus"] = False # 解決負號顯示問題
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  tree = None
21
  vs30_table = None
 
23
  try:
24
  logger.info("從 Hugging Face 載入 Vs30 資料...")
25
  vs30_file = hf_hub_download(
26
+ repo_id="SeisBlue/TaiwanVs30", filename="Vs30ofTaiwan.nc",
 
27
  repo_type="dataset"
28
  )
29
  ds = xr.open_dataset(vs30_file)
30
+ lat_flat = ds["lat"].values.flatten()
31
+ lon_flat = ds["lon"].values.flatten()
32
+ vs30_flat = ds["vs30"].values.flatten()
33
 
34
+ vs30_table = pd.DataFrame(
35
+ {"lat": lat_flat, "lon": lon_flat, "Vs30": vs30_flat})
36
  vs30_table = vs30_table.replace([np.inf, -np.inf], np.nan).dropna()
37
  tree = cKDTree(vs30_table[["lat", "lon"]])
38
  logger.info("Vs30 資料載入完成")
 
49
 
50
  # 驗證 site_info.csv 必要欄位
51
  required_site_fields = ["Station", "Latitude", "Longitude", "Elevation"]
52
+ missing_site_fields = [
53
+ f for f in required_site_fields if f not in site_info.columns
54
+ ]
55
  if missing_site_fields:
56
+ logger.error(
57
+ f"{site_info_file} 缺少必要欄位: {missing_site_fields}")
58
+ raise ValueError(
59
+ f"site_info.csv 缺少必要欄位: {missing_site_fields}")
60
 
61
  # 只保留唯一的測站(去除重複的分量)
62
+ site_info = site_info.drop_duplicates(subset=["Station"]).reset_index(
63
+ drop=True)
64
  logger.info(f"{site_info_file} 載入完成,共 {len(site_info)} 個測站")
65
  except FileNotFoundError:
66
  logger.warning(f"{site_info_file} 找不到")
 
74
  target_df = pd.read_csv(target_file)
75
 
76
  # 驗證 eew_target.csv 必要欄位
77
+ required_target_fields = ["station", "latitude", "longitude",
78
+ "elevation"]
79
+ missing_target_fields = [
80
+ f for f in required_target_fields if f not in target_df.columns
81
+ ]
82
  if missing_target_fields:
83
  logger.error(f"{target_file} 缺少必要欄位: {missing_target_fields}")
84
+ raise ValueError(
85
+ f"eew_target.csv 缺少必要欄位: {missing_target_fields}")
86
 
87
  target_dict = target_df.to_dict(orient="records")
88
  logger.info(f"{target_file} 載入完成(共 {len(target_dict)} 個目標點)")
 
91
  except Exception as e:
92
  logger.error(f"{target_file} 載入失敗: {e}")
93
 
 
 
 
 
 
94
 
95
  # ============ 震央資訊管理 ============
96
 
97
+ earthquake_metadata = {}
98
+ event_json_path="waveform/event.json"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ try:
101
+ import json
102
+
103
+ with open(event_json_path, "r", encoding="utf-8") as f:
104
+ data = json.load(f)
105
+
106
+ if "events" not in data:
107
+ logger.error(f"{event_json_path} 缺少 'events' 鍵")
108
+
109
+ # 將事件列表轉換為以 event_name 為鍵的字典
110
+ for event in data["events"]:
111
+ event_name = event.get("event_name")
112
+ if event_name:
113
+ earthquake_metadata[event_name] = {
114
+ "event_id": event.get("event_id"),
115
+ "event_name": event.get("event_name"),
116
+ "timestamp": event.get("timestamp"),
117
+ "first_pick": event.get("first_pick"),
118
+ "mseed_file": event.get("mseed_file"),
119
+ "intensity_map_file": event.get("intensity_map_file"),
120
+ "epicenter_lat": event.get("epicenter_lat"),
121
+ "epicenter_lon": event.get("epicenter_lon"),
122
+ "depth_km": event.get("depth_km"),
123
+ "magnitude": event.get("magnitude"),
124
+ }
125
+ logger.info(
126
+ f"載入事件: {event_name} | 震央: ({event.get('epicenter_lon')}, {event.get('epicenter_lat')})"
127
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
+ logger.info(f"地震事件元資料載入完成(共 {len(earthquake_metadata)} 個事件)")
 
 
130
 
131
+ except FileNotFoundError:
132
+ logger.error(f"事件元資料檔案缺失: {event_json_path}")
133
 
134
 
135
+ except Exception as e:
136
+ logger.error(f"讀取事件元資料時發生錯誤: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
 
139
  # 載入模型
140
  model_path = hf_hub_download(
141
+ repo_id="SeisBlue/TTSAM", filename="ttsam_trained_model_11.pt"
 
142
  )
143
  model = get_full_model(model_path)
144
 
145
 
146
  # ============ 輔助函數 ============
147
 
148
+
149
  def lowpass(data, freq=10, df=100, corners=4):
150
  fe = 0.5 * df
151
  f = freq / fe
 
173
  return float(vs30)
174
 
175
 
 
176
  def calculate_intensity(pga, label=False):
177
  intensity_label = ["0", "1", "2", "3", "4", "5-", "5+", "6-", "6+", "7"]
178
  pga_level = np.log10([1e-5, 0.008, 0.025, 0.080, 0.250, 0.80, 1.4, 2.5, 4.4, 8.0])
 
188
 
189
  # ============ Gradio 介面函數 ============
190
 
 
 
 
 
 
 
191
 
192
  def calculate_distance(lat1, lon1, lat2, lon2):
193
  """計算兩點間的距離(簡化的平面距離,單位:度)"""
194
+ return np.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2)
195
 
196
 
197
  def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
 
218
 
219
  # 驗證必要欄位存在
220
  required_fields = ["Latitude", "Longitude", "Elevation"]
221
+ missing_fields = [
222
+ f for f in required_fields if f not in station_data.columns
223
+ ]
224
  if missing_fields:
225
+ logger.warning(
226
+ f"測站 {station_code} 缺少必要欄位: {missing_fields},跳過"
227
+ )
228
  continue
229
 
230
  lat = station_data["Latitude"].values[0]
 
237
  "distance": distance,
238
  "latitude": lat,
239
  "longitude": lon,
240
+ "elevation": elev,
241
  }
242
  except Exception as e:
243
  logger.warning(f"測站 {station_code} 資訊查詢失敗: {e}")
 
251
  # 記錄實際可用的測站數(少於 25 站也允許繼續)
252
  actual_count = len(selected_stations)
253
  if actual_count < n_stations:
254
+ logger.warning(
255
+ f"僅找到 {actual_count} 個可用測站(目標 {n_stations} 個),將繼續處理"
256
+ )
257
  else:
258
+ logger.info(
259
+ f"從 {len(station_list)} 個輸入測站中選擇了最近的 {actual_count} 個"
260
+ )
261
 
262
  return selected_stations
263
 
264
 
265
+ def extract_waveforms_from_stream(event_name,
266
+ st, selected_stations, duration, vs30_input
267
+ ):
268
  """
269
  從 Stream 中提取選定測站的波形資料
270
 
 
294
  sampling_rate = 100 # 100 Hz
295
  min_duration = 30.0 # 最小時間長度 30 秒
296
  target_length = 3000 # 30 秒 @ 100 Hz = 3000 samples
297
+ first_pick = earthquake_metadata[event_name]["first_pick"]
298
 
299
  # 內部計算 end_time(接受 start/duration 參數)
300
+ end_time = first_pick + duration
301
 
302
+ start_idx = 0
303
  end_idx = int(end_time * sampling_rate)
304
  actual_samples = end_idx - start_idx
305
 
306
  # 檢查是否需要零填充:長度不足 30 秒時尾段以 0 遮罩補齊
307
  needs_padding = duration < min_duration
308
  if needs_padding:
309
+ logger.info(
310
+ f"時間長度 {duration} 秒 < 30 秒,將以 0 遮罩補齊至 {min_duration} 秒"
311
+ )
312
 
313
  for station_data in selected_stations:
314
  station_code = station_data["station"]
 
323
 
324
  # 嘗試取得 Z, N, E 分量
325
  z_trace = st_station.select(component="Z")
326
+ n_trace = st_station.select(component="N") or st_station.select(
327
+ component="1"
328
+ )
329
+ e_trace = st_station.select(component="E") or st_station.select(
330
+ component="2"
331
+ )
332
 
333
  # 檢查 Z 分量(必須存在)
334
  if len(z_trace) > 0:
 
377
  waveforms.append(waveform_3c)
378
 
379
  # 準備測站資訊
380
+ vs30 = get_vs30(
381
+ station_data["latitude"], station_data["longitude"], vs30_input
382
+ )
383
+ station_info_list.append(
384
+ [
385
+ station_data["latitude"],
386
+ station_data["longitude"],
387
+ station_data["elevation"],
388
+ vs30,
389
+ ]
390
+ )
391
  valid_stations.append(station_data)
392
 
393
  except Exception as e:
 
396
 
397
  logger.info(f"成功提取 {len(waveforms)} 個測站的波形")
398
  if missing_components_count > 0:
399
+ logger.info(
400
+ f"其中 {missing_components_count} 個測站缺少 N 或 E 分量(已以 Z 分量代替)"
401
+ )
402
 
403
  return waveforms, station_info_list, valid_stations, missing_components_count
404
 
405
+
406
  def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
407
  """創建輸入測站分布地圖:顯示所有測站 + 突顯被選中的 25 個(使用 Plotly)"""
408
 
 
422
  all_stations_text.append(station_code)
423
 
424
  # 準備選中測站資料(按距離分組)
425
+ selected_group1_lat, selected_group1_lon, selected_group1_text = (
426
+ [],
427
+ [],
428
+ [],
429
+ ) # 前 5 近
430
+ selected_group2_lat, selected_group2_lon, selected_group2_text = (
431
+ [],
432
+ [],
433
+ [],
434
+ ) # 6-15 近
435
+ selected_group3_lat, selected_group3_lon, selected_group3_text = (
436
+ [],
437
+ [],
438
+ [],
439
+ ) # 16-25 近
440
 
441
  for i, station_data in enumerate(selected_stations):
442
  station_code = station_data["station"]
 
463
  fig = go.Figure()
464
 
465
  # 添加所有測站(灰色小點)
466
+ fig.add_trace(
467
+ go.Scattermap(
468
+ lat=all_stations_lat,
469
+ lon=all_stations_lon,
470
+ mode="markers",
471
+ marker=dict(size=6, color="gray", opacity=0.6),
472
+ text=all_stations_text,
473
+ hovertemplate="%{text}<extra></extra>",
474
+ name=f"所有測站 ({len(all_stations_lat)} 個)",
475
+ showlegend=True,
476
+ )
477
+ )
478
 
479
  # 添加選中測站 - 前 5 近(綠色)
480
  if selected_group1_lat:
481
+ fig.add_trace(
482
+ go.Scattermap(
483
+ lat=selected_group1_lat,
484
+ lon=selected_group1_lon,
485
+ mode="markers",
486
+ marker=dict(size=12, color="green", opacity=0.8),
487
+ text=selected_group1_text,
488
+ hovertemplate="%{text}<extra></extra>",
489
+ name="前 5 近",
490
+ showlegend=True,
491
+ )
492
+ )
493
 
494
  # 添加選中測站 - 6-15 近(藍色)
495
  if selected_group2_lat:
496
+ fig.add_trace(
497
+ go.Scattermap(
498
+ lat=selected_group2_lat,
499
+ lon=selected_group2_lon,
500
+ mode="markers",
501
+ marker=dict(size=12, color="blue", opacity=0.8),
502
+ text=selected_group2_text,
503
+ hovertemplate="%{text}<extra></extra>",
504
+ name="6-15 近",
505
+ showlegend=True,
506
+ )
507
+ )
508
 
509
  # 添加選中測站 - 16-25 近(橘色)
510
  if selected_group3_lat:
511
+ fig.add_trace(
512
+ go.Scattermap(
513
+ lat=selected_group3_lat,
514
+ lon=selected_group3_lon,
515
+ mode="markers",
516
+ marker=dict(size=12, color="orange", opacity=0.8),
517
+ text=selected_group3_text,
518
+ hovertemplate="%{text}<extra></extra>",
519
+ name="16-25 近",
520
+ showlegend=True,
521
+ )
522
+ )
523
 
524
  # 添加震央(紅色大點)
525
+ fig.add_trace(
526
+ go.Scattermap(
527
+ lat=[epicenter_lat],
528
+ lon=[epicenter_lon],
529
+ mode="markers",
530
+ marker=dict(size=25, color="red"),
531
+ text=[f"震央<br>({epicenter_lat:.3f}, {epicenter_lon:.3f})"],
532
+ hovertemplate="%{text}<extra></extra>",
533
+ name="震央",
534
+ showlegend=True,
535
+ )
536
+ )
537
+
538
+ fig.add_trace(
539
+ go.Scattermap(
540
+ lat=[epicenter_lat],
541
+ lon=[epicenter_lon],
542
+ mode="markers",
543
+ marker=dict(size=10, color="white"),
544
+ showlegend=False,
545
+ )
546
+ )
547
 
548
  # 設置地圖佈局
549
  fig.update_layout(
550
  map=dict(
551
  style="open-street-map",
552
  center=dict(lat=epicenter_lat, lon=epicenter_lon),
553
+ zoom=7,
554
  ),
555
  height=500, # 設置固定高度以適應 Gradio 容器
556
  margin=dict(l=0, r=0, t=0, b=0),
 
560
  y=0.95,
561
  xanchor="left",
562
  x=0.01,
563
+ bgcolor="rgba(255, 255, 255, 0.8)",
564
+ ),
565
  )
566
 
567
  return fig
568
 
569
 
570
+ def plot_waveform(st, selected_stations, first_pick, duration):
571
  """
572
  繪製選定測站的波形圖(距離-時間圖,可顯示全部 25 個測站)
573
 
 
578
  - duration: 時間長度(秒)
579
  """
580
  # 計算結束時間
581
+ end_time = first_pick + duration
582
 
583
  fig, ax = plt.subplots(figsize=(14, 10))
584
 
 
600
  times = tr.times()
601
  data = tr.data
602
 
603
+ # 只顯示從資料開始到 30 秒內的波形
604
+ time_mask = times <= 120.0
605
+ times = times[time_mask]
606
+ data = data[time_mask]
607
+
608
  # 正規化波形振幅
609
  data_normalized = data / (np.max(np.abs(data)) + 1e-10)
610
 
611
  # 繪製波形,Y軸位置為距離
612
+ ax.plot(
613
+ times,
614
+ distance + data_normalized * amplitude_scale,
615
+ "black",
616
+ linewidth=0.3,
617
+ alpha=0.8,
618
+ )
619
 
620
  distances.append(distance)
621
  station_names.append(station_code)
 
624
  except Exception as e:
625
  logger.warning(f"無法繪製測站 {station_code}: {e}")
626
 
627
+ ax.axvline(first_pick, color="blue", linestyle="--", linewidth=2, alpha=0.7, label="初動時間")
628
+
629
  # 標記選取時間範圍
630
+ ax.axvline(
631
+ 0,
632
+ color="red",
633
+ linestyle="--",
634
+ linewidth=2,
635
+ alpha=0.7,
636
+ label="選取範圍",
637
+ )
638
+ ax.axvline(end_time, color="red", linestyle="--", linewidth=2, alpha=0.7)
639
+ ax.axvspan(0, end_time, alpha=0.15, color="blue")
640
 
641
  # 設定軸標籤和標題
642
+ ax.set_xlabel("Time (s)", fontsize=12)
643
+ ax.set_ylabel("Distance from Epicenter (°)", fontsize=12)
644
+ ax.set_title(
645
+ f"Record Section - {plotted_count} Stations Sorted by Distance",
646
+ fontsize=14,
647
+ fontweight="bold",
648
+ )
649
 
650
  # 在右側標註測站名稱
651
  if distances:
652
  ax2 = ax.twinx()
653
  ax2.set_ylim(ax.get_ylim())
654
+ ax2.set_ylabel("Station Code", fontsize=12)
655
 
656
  # 每隔幾個測站標註一次(避免過於擁擠)
657
  step = max(1, len(distances) // 10)
 
660
  ax2.set_yticks(tick_positions)
661
  ax2.set_yticklabels(tick_labels, fontsize=8)
662
 
663
+ ax.grid(True, alpha=0.3, axis="x")
664
+ ax.legend(loc="upper right")
665
  plt.tight_layout()
666
 
667
  return fig
 
684
  return color_map.get(intensity, "#ffffff")
685
 
686
 
687
+ def create_intensity_map(
688
+ pga_list, target_names, epicenter_lat=None, epicenter_lon=None
689
+ ):
690
  """使用 Plotly 創建互動式震度分布地圖"""
691
 
692
  # 按震度等級分組資料
693
+ intensity_groups = {
694
+ i: {"lat": [], "lon": [], "text": [], "color": get_intensity_color(i)}
695
+ for i in range(10)
696
+ }
697
 
698
  # 添加震度測站標記
699
+ all_lats = []
700
+ all_lons = []
701
  for i, target_name in enumerate(target_names):
702
  target = next((t for t in target_dict if t["station"] == target_name), None)
703
  if target:
704
  lat = target["latitude"]
705
  lon = target["longitude"]
706
+ all_lats.append(lat)
707
+ all_lons.append(lon)
708
  intensity = calculate_intensity(pga_list[i])
709
  intensity_label = calculate_intensity(pga_list[i], label=True)
710
  pga = pga_list[i]
711
 
712
+ hover_text = (
713
+ f"{target_name}<br>"
714
+ f"震度: {intensity_label}<br>"
715
+ f"PGA: {pga:.4f} m/s²<br>"
716
+ f"位置: ({lat:.3f}, {lon:.3f})"
717
+ )
718
 
719
+ intensity_groups[intensity]["lat"].append(lat)
720
+ intensity_groups[intensity]["lon"].append(lon)
721
+ intensity_groups[intensity]["text"].append(hover_text)
722
+
723
+ # 決定地圖中心(優先使用 epicenter,否則計算平均值)
724
+ if epicenter_lat is not None and epicenter_lon is not None:
725
+ map_center_lat = epicenter_lat
726
+ map_center_lon = epicenter_lon
727
+ elif all_lats and all_lons:
728
+ map_center_lat = np.mean(all_lats)
729
+ map_center_lon = np.mean(all_lons)
730
+ else:
731
+ # 如果沒有任何測站資料,使用台灣的中心
732
+ map_center_lat = 23.5
733
+ map_center_lon = 121.0
734
+ logger.warning("無法決定地圖中心,使用台灣預設中心")
735
 
736
  # 創建 Plotly 地圖
737
  fig = go.Figure()
 
740
  intensity_labels = ["0", "1", "2", "3", "4", "5-", "5+", "6-", "6+", "7"]
741
  for intensity_level in range(10):
742
  group = intensity_groups[intensity_level]
743
+ if group["lat"]: # 有資料的震度等級
744
+ fig.add_trace(
745
+ go.Scattermap(
746
+ lat=group["lat"],
747
+ lon=group["lon"],
748
+ mode="markers",
749
+ marker=dict(size=14, color=group["color"], opacity=0.8),
750
+ text=group["text"],
751
+ hovertemplate="%{text}<extra></extra>",
752
+ name=f"震度 {intensity_labels[intensity_level]}",
753
+ showlegend=True,
754
+ )
755
+ )
 
 
756
  else:
757
  # 沒有資料的震度等級:添加隱形標記只為了顯示圖例
758
+ fig.add_trace(
759
+ go.Scattermap(
760
+ lat=[None],
761
+ lon=[None],
762
+ mode="markers",
763
+ marker=dict(size=14, color=group["color"], opacity=0.8),
764
+ name=f"震度 {intensity_labels[intensity_level]}",
765
+ showlegend=True,
766
+ hoverinfo="skip",
767
+ )
768
+ )
 
 
769
 
770
  # 設置地圖佈局
771
  fig.update_layout(
772
  map=dict(
773
  style="open-street-map",
774
+ center=dict(lat=map_center_lat, lon=map_center_lon),
775
+ zoom=7,
 
776
  ),
777
  height=800, # 設置固定高度以適應 Gradio 容器
778
  margin=dict(l=0, r=0, t=0, b=0),
 
782
  y=0.95,
783
  xanchor="left",
784
  x=0.01,
785
+ bgcolor="rgba(255, 255, 255, 0.8)",
786
+ ),
787
  )
788
 
789
  return fig
790
 
791
 
 
792
  def load_observed_intensity_image(event_name):
793
  """
794
  從 intensity_map 資料夾載入對應的實際觀測震度圖
 
797
  """
798
  import os
799
 
800
+ image_path = earthquake_metadata[event_name]["intensity_map_file"]
801
+ if os.path.exists(image_path):
802
+ logger.info(f"載入實際觀測震度圖: {image_path}")
803
+ return image_path
 
804
 
805
+ logger.warning(f"找不到實際震度圖: {event_name}(將顯示空白占位)")
 
 
 
 
 
 
806
  return None
807
 
808
 
809
+ def load_and_display_waveform(event_name, duration):
810
  """載入並顯示波形,讓使用者確認範圍
811
 
812
  從全域 earthquake_metadata 讀取震央座標
 
815
  """
816
  try:
817
  # 從全域 earthquake_metadata 讀取震央座標
818
+ epicenter_lat = earthquake_metadata[event_name]["epicenter_lat"]
819
+ epicenter_lon = earthquake_metadata[event_name]["epicenter_lon"]
820
+ mseed_file = earthquake_metadata[event_name]["mseed_file"]
821
+ first_pick = earthquake_metadata[event_name]["first_pick"]
822
 
823
  # 計算結束時間(用於顯示資訊)
824
+
825
 
826
  # 1. 載入完整的 mseed 檔案
827
  logger.info(f"載入地震事件: {event_name}")
828
+ st = read(mseed_file)
829
  logger.info(f"��入了 {len(st)} 個 trace")
830
 
831
  # 2. 根據震央距離選擇最近的 25 個測站
832
  logger.info(f"選擇距離震央 ({epicenter_lat}, {epicenter_lon}) 最近的測站...")
833
+ selected_stations = select_nearest_stations(
834
+ st, epicenter_lat, epicenter_lon, n_stations=25
835
+ )
836
 
837
  if len(selected_stations) == 0:
838
+ return (
839
+ None,
840
+ None,
841
+ "錯誤:找不到有效的測站資料",
842
+ gr.update(interactive=False),
843
+ )
844
 
845
+ waveform_plot = plot_waveform(st, selected_stations, first_pick, duration)
 
846
 
847
  # 4. 創建輸入測站地圖
848
+ station_map = create_input_station_map(
849
+ selected_stations, epicenter_lat, epicenter_lon
850
+ )
851
 
852
  logger.info("波形載入完成")
853
  return station_map, waveform_plot, gr.update(interactive=True)
 
855
  except Exception as e:
856
  logger.error(f"波形載入發生錯誤: {e}")
857
  import traceback
858
+
859
  traceback.print_exc()
860
  return None, None, gr.update(interactive=False)
861
 
862
 
863
+ def predict_intensity(event_name, duration):
864
  """
865
  執行震度預測
866
 
 
872
  """
873
  try:
874
  # 從全域 earthquake_metadata 讀取震央座標
875
+ epicenter_lat = earthquake_metadata[event_name]["epicenter_lat"]
876
+ epicenter_lon = earthquake_metadata[event_name]["epicenter_lon"]
877
+ mseed_file = earthquake_metadata[event_name]["mseed_file"]
878
 
 
 
879
 
880
  # 1. 載入完整的 mseed 檔案
881
  logger.info(f"載入地震事件: {event_name}")
882
+ st = read(mseed_file)
883
  logger.info(f"載入了 {len(st)} 個 trace")
884
 
885
  # 2. 根據震央距離選擇最近的 25 個測站
886
  logger.info(f"選擇距離震央 ({epicenter_lat}, {epicenter_lon}) 最近的測站...")
887
+ selected_stations = select_nearest_stations(
888
+ st, epicenter_lat, epicenter_lon, n_stations=25
889
+ )
890
 
891
  if len(selected_stations) == 0:
892
  return None, None, "錯誤:找不到有效的測站資料"
893
 
894
  # 3. 從選定的測站提取波形(vs30_input 使用預設值 600,會被資料庫值覆蓋)
895
+ logger.info(
896
+ f"提取波形資料(時間範圍: 初動後 {duration} 秒)..."
897
+ )
898
+ waveforms, station_info_list, valid_stations, missing_components_count = (
899
+ extract_waveforms_from_stream(
900
+ event_name, st, selected_stations, duration, vs30_input=600
901
+ )
902
  )
903
 
904
  if len(waveforms) == 0:
 
922
  total_targets = len(target_dict)
923
  num_batches = (total_targets + batch_size - 1) // batch_size
924
 
925
+ logger.info(
926
+ f"開始分批預測 {total_targets} 個目標測站(共 {num_batches} 批)..."
927
+ )
928
 
929
  for batch_idx in range(num_batches):
930
  start_idx = batch_idx * batch_size
931
  end_idx = min((batch_idx + 1) * batch_size, total_targets)
932
  batch_targets = target_dict[start_idx:end_idx]
933
 
934
+ logger.info(
935
+ f"預測第 {batch_idx + 1}/{num_batches} 批(測站 {start_idx + 1}-{end_idx})..."
936
+ )
937
 
938
  # 準備這批目標測站資訊
939
  target_list = []
940
  target_names = []
941
  for target in batch_targets:
942
+ target_list.append(
943
+ [
944
+ target["latitude"],
945
+ target["longitude"],
946
+ target["elevation"],
947
+ get_vs30(
948
+ target["latitude"], target["longitude"], user_vs30=600
949
+ ),
950
+ ]
951
+ )
952
  target_names.append(target["station"])
953
 
954
  # Padding 到 25 個(如果不足 25 個)
 
966
  # 7. 執行預測
967
  with torch.no_grad():
968
  weight, sigma, mu = model(tensor_data)
969
+ batch_pga = (
970
+ torch.sum(weight * mu, dim=2)
971
+ .cpu()
972
+ .detach()
973
+ .numpy()
974
+ .flatten()
975
+ .tolist()
976
+ )
977
 
978
  # 只取實際有資料的部分
979
+ all_pga_list.extend(batch_pga[: len(target_names)])
980
  all_target_names.extend(target_names)
981
 
982
  logger.info(f"完成所有 {len(all_target_names)} 個測站的預測!")
 
984
  target_names = all_target_names
985
 
986
  # 繪製互動式地圖(固定高度 800)
987
+ intensity_map = create_intensity_map(
988
+ pga_list, target_names, epicenter_lat, epicenter_lon
989
+ )
990
 
991
  # 載入實際觀測震度圖(filepath;左側以 800 高顯示)
992
  observed_intensity_path = load_observed_intensity_image(event_name)
 
997
  except Exception as e:
998
  logger.error(f"預測過程發生錯誤: {e}")
999
  import traceback
1000
+
1001
  traceback.print_exc()
1002
  return None, None
1003
 
1004
 
1005
+ def on_full_workflow(event_name, duration):
1006
  """
1007
  執行完整的工作流:波形載入 → 測站選擇 → 推論 → 結果展示
1008
 
 
1020
  # 步驟 1: 載入波形
1021
  logger.info(f"[on_full_workflow] 步驟 1/3: 波形載入...")
1022
  station_map, waveform_plot, _ = load_and_display_waveform(
1023
+ event_name, duration
1024
  )
1025
 
1026
  if station_map is None:
 
1030
  # 步驟 2: 執行推論
1031
  logger.info(f"[on_full_workflow] 步驟 2/3: 模型推論...")
1032
  observed_img, predicted_map = predict_intensity(
1033
+ event_name, duration
1034
  )
1035
 
1036
  if predicted_map is None:
 
1044
  except Exception as e:
1045
  logger.error(f"[on_full_workflow] 完整工作流發生錯誤: {e}")
1046
  import traceback
1047
+
1048
  traceback.print_exc()
1049
  return None, None, f"錯誤: {str(e)}", None, f"錯誤: {str(e)}", None
1050
 
 
1051
  # ============ Gradio 介面 ============
 
1052
  with gr.Blocks(title="TTSAM 震度預測系統", fill_height=True) as demo:
1053
  gr.Markdown("# 🌏 TTSAM 震度預測系統")
1054
 
 
1057
  # 左上:使用步驟與狀態顯示
1058
  with gr.Column(scale=1):
1059
  gr.Markdown("## 使用步驟")
1060
+ gr.Markdown(
1061
+ """
1062
  1. 選擇地震事件和時間範圍
1063
  2. 輸入震央位置和場址參數
1064
  3. 點擊載入波形確認波形範圍
1065
  4. 確認無誤後點擊執行預測
1066
 
1067
  系統會自動選擇距離震央最近的 25 個測站
1068
+ """
1069
+ )
1070
  with gr.Column(scale=1):
1071
  event_dropdown = gr.Dropdown(
1072
+ choices=list(earthquake_metadata.keys()),
1073
+ value=list(earthquake_metadata.keys())[0],
1074
+ label="選擇地震事件",
1075
  )
1076
  # ========== 中層:輸入測站地圖與波形圖 ==========
1077
  with gr.Row():
 
1080
  gr.Markdown("## 輸入測站分布")
1081
  input_station_map = gr.Plot(label="輸入測站地圖")
1082
  load_waveform_btn = gr.Button("📊 載入波形", variant="secondary", scale=1)
1083
+ predict_btn = gr.Button(
1084
+ "🔮 執行預測", variant="primary", scale=1, interactive=False
1085
+ )
1086
 
1087
  # 中右:輸入波形
1088
  with gr.Column(scale=1):
 
1091
  label="地震波形(選定的 25 個測站)",
1092
  )
1093
  with gr.Row():
1094
+ duration_slider = gr.Slider(
1095
+ 2, 15, value=15, step=1, label="P 波後時間 (秒)"
1096
+ )
1097
 
1098
  # ========== 下層:實際觀測 vs 預測結果 ==========
1099
  with gr.Row():
 
1102
  gr.Markdown("## 預測震度分布")
1103
  predicted_intensity_map = gr.Plot(label="互動式震度地圖")
1104
 
 
1105
  # 右下:實際觀測震度圖
1106
  with gr.Column(scale=1):
1107
  gr.Markdown("## 實際觀測震度分布")
 
1109
  label="實際觀測震度",
1110
  type="filepath",
1111
  height=800,
1112
+ value=load_observed_intensity_image(list(earthquake_metadata.keys())[0]),
1113
  )
1114
 
1115
  # 綁定事件
1116
  event_dropdown.change(
1117
+ fn=lambda event_name, duration: (
1118
+ *on_full_workflow(event_name, duration),
1119
  ),
1120
+ inputs=[event_dropdown, duration_slider],
1121
+ outputs=[
1122
+ input_station_map,
1123
+ waveform_plot,
1124
+ predicted_intensity_map,
1125
+ observed_intensity_image,
1126
+ ],
1127
  )
1128
 
1129
  load_waveform_btn.click(
1130
  fn=load_and_display_waveform,
1131
+ inputs=[event_dropdown, duration_slider],
1132
+ outputs=[input_station_map, waveform_plot, predict_btn],
1133
  )
1134
 
1135
  predict_btn.click(
1136
  fn=predict_intensity,
1137
+ inputs=[event_dropdown, duration_slider],
1138
+ outputs=[observed_intensity_image, predicted_intensity_map],
1139
  )
1140
 
1141
  # 應用啟動時自動執行完整工作流
1142
  demo.load(
1143
+ fn=lambda event_name, duration: (
1144
+ *on_full_workflow(event_name, duration),
1145
  ),
1146
+ inputs=[event_dropdown, duration_slider],
1147
+ outputs=[
1148
+ input_station_map,
1149
+ waveform_plot,
1150
+ predicted_intensity_map,
1151
+ observed_intensity_image,
1152
+ ],
1153
  )
1154
 
1155
  demo.launch()
intensity_map/{20240403.png → 2021102413113465103_H.png} RENAMED
File without changes
intensity_map/2022091814441568111_H.png ADDED

Git LFS Details

  • SHA256: 97dbd6b00dfc4e476ec2d29b38121f01cd6566ecd567fdb76fbaa8317e4b5069
  • Pointer size: 131 Bytes
  • Size of remote file: 577 kB
intensity_map/2024040307580972019_H.png ADDED

Git LFS Details

  • SHA256: 365461a9eb7d19cac733b662636d2fa101deff5f504ff2df2a761c03883eff28
  • Pointer size: 131 Bytes
  • Size of remote file: 587 kB
intensity_map/2025012100172764007_H.png ADDED

Git LFS Details

  • SHA256: 32dc5aa10f81663373c8efd62eac15d4edf7101c9b87e1245f9d420ba745e88c
  • Pointer size: 131 Bytes
  • Size of remote file: 581 kB
waveform/20211024.mseed ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1c02a7754ae0ad6a45a5a5bef3220220e47bd4b867be89d34b5df94cf33862a8
3
+ size 11558912
waveform/20220918.mseed ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c4683e99abd8e69f7fc6d8f16aa7c7da3de7522320bbb3483930c16415ee90b
3
+ size 17133568
waveform/20240403.mseed CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:2706992997b8eb30e568c3470e6f1d8c99654b8a4b1a12b33099fe91900cd51a
3
- size 37216256
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e8beedc2a60382dd657ca588271db0f6808d45fbd7e4704ff2c09ef76f0e6f0
3
+ size 22388736
waveform/20250120.mseed ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:199bae2e8477f06ea53d085dc3573407a6f8e50b4c798edd999751340d61d4e7
3
+ size 20357120
waveform/event.json CHANGED
@@ -1,16 +1,52 @@
1
  {
2
  "events": [
3
  {
4
- "event_id": "20240403",
5
- "event_name": "0403花蓮地震 (2024)",
6
- "epicenter_lat": 23.88,
7
- "epicenter_lon": 121.57,
8
- "depth_km": 25.0,
9
- "magnitude": 7.2,
10
- "timestamp": "2024-04-03T07:58:00Z",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  "mseed_file": "waveform/20240403.mseed",
12
- "intensity_map_file": "intensity_map/20240403.png"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
  ]
15
  }
16
-
 
1
  {
2
  "events": [
3
  {
4
+ "event_id": "20211024051134",
5
+ "event_name": "1024 宜蘭外海地震 (2021)",
6
+ "timestamp": "2021-10-24T05:11:34Z",
7
+ "first_pick": 11.15,
8
+ "mseed_file": "waveform/20211024.mseed",
9
+ "intensity_map_file": "intensity_map/2021102413113465103_H.png",
10
+ "epicenter_lat": 24.53,
11
+ "epicenter_lon": 121.79,
12
+ "depth_km": 66.8,
13
+ "magnitude": 6.5
14
+ },
15
+ {
16
+ "event_id": "20220918064415",
17
+ "event_name": "0918 池上地震 (2022)",
18
+ "timestamp": "2022-09-18T06:44:15Z",
19
+ "first_pick": 2.0,
20
+ "mseed_file": "waveform/20220918.mseed",
21
+ "intensity_map_file": "intensity_map/2022091814441568111_H.png",
22
+ "epicenter_lat": 23.14,
23
+ "epicenter_lon": 121.2,
24
+ "depth_km": 7.0,
25
+ "magnitude": 6.8
26
+ },
27
+ {
28
+ "event_id": "20240402235809",
29
+ "event_name": "0403 花蓮地震 (2024)",
30
+ "timestamp": "2024-04-02T23:58:09Z",
31
+ "first_pick": 5.3,
32
  "mseed_file": "waveform/20240403.mseed",
33
+ "intensity_map_file": "intensity_map/2024040307580972019_H.png",
34
+ "epicenter_lat": 23.77,
35
+ "epicenter_lon": 121.67,
36
+ "depth_km": 15.5,
37
+ "magnitude": 7.2
38
+ },
39
+ {
40
+ "event_id": "20250120161727",
41
+ "event_name": "0120 大埔地震 (2025)",
42
+ "timestamp": "2025-01-20T16:17:27Z",
43
+ "first_pick": 3.55,
44
+ "mseed_file": "waveform/20250120.mseed",
45
+ "intensity_map_file": "intensity_map/2025012100172764007_H.png",
46
+ "epicenter_lat": 23.23,
47
+ "epicenter_lon": 120.57,
48
+ "depth_km": 9.7,
49
+ "magnitude": 6.4
50
  }
51
  ]
52
  }