jimmy60504 commited on
Commit
6d209c3
·
1 Parent(s): c56b1d8

feat: add automatic P-wave detection using STA/LTA method and enhance waveform visualization

Browse files
Files changed (2) hide show
  1. app.py +116 -7
  2. changelog.md +39 -0
app.py CHANGED
@@ -8,6 +8,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
 
@@ -160,6 +161,50 @@ def signal_processing(waveform):
160
  return data
161
 
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def get_vs30(lat, lon, user_vs30=600):
164
  if tree is None or vs30_table is None:
165
  # 如果 Vs30 資料未載入,使用使用者輸入的值
@@ -260,12 +305,15 @@ def calculate_distance(lat1, lon1, lat2, lon2):
260
  def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
261
  """
262
  從 site_info(1000+ 個輸入測站)中選擇距離震央最近的 n 個測站
 
263
 
264
  少於 25 站可用:UI 明示實際用站數並允許繼續
265
  """
266
  station_distances = {} # 改用字典避免重複
 
 
267
 
268
- # 計算每個測站到震央的距離
269
  for tr in st:
270
  station_code = tr.stats.station
271
 
@@ -294,6 +342,21 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
294
  lon = station_data["Longitude"].values[0]
295
  elev = station_data["Elevation"].values[0]
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  distance = calculate_distance(epicenter_lat, epicenter_lon, lat, lon)
298
  station_distances[station_code] = {
299
  "station": station_code,
@@ -301,9 +364,13 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
301
  "latitude": lat,
302
  "longitude": lon,
303
  "elevation": elev,
 
304
  }
 
 
305
  except Exception as e:
306
  logger.warning(f"測站 {station_code} 資訊查詢失敗: {e}")
 
307
  continue
308
 
309
  # 轉換為列表並按距離排序,選擇最近的 n 個
@@ -313,6 +380,8 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
313
 
314
  # 記錄實際可用的測站數(少於 25 站也允許繼續)
315
  actual_count = len(selected_stations)
 
 
316
  if actual_count < n_stations:
317
  logger.warning(
318
  f"僅找到 {actual_count} 個可用測站(目標 {n_stations} 個),將繼續處理"
@@ -343,16 +412,19 @@ def extract_waveforms_from_stream(event_name,
343
  - station_info_list: 測站資訊列表
344
  - valid_stations: 有效測站列表
345
  - missing_components_count: 缺少分量的測站數量
 
346
 
347
  Note:
348
  - 內部計算 end_time = start_time + duration
349
  - 若 duration < 30 秒,尾段以 0 遮罩補齊至 30 秒(3000 samples @ 100 Hz)
350
  - 缺少 N/E 分量時以 Z 分量代替,並在狀態訊息中記錄缺分量站數
 
351
  """
352
  waveforms = []
353
  station_info_list = []
354
  valid_stations = []
355
  missing_components_count = 0
 
356
 
357
  sampling_rate = 100 # 100 Hz
358
  min_duration = 30.0 # 最小時間長度 30 秒
@@ -374,6 +446,14 @@ def extract_waveforms_from_stream(event_name,
374
  )
375
 
376
  for station_data in selected_stations:
 
 
 
 
 
 
 
 
377
  station_code = station_data["station"]
378
  station_missing_components = False
379
 
@@ -462,18 +542,23 @@ def extract_waveforms_from_stream(event_name,
462
  logger.info(
463
  f"其中 {missing_components_count} 個測站缺少 N 或 E 分量(已以 Z 分量代替)"
464
  )
 
 
 
 
465
 
466
- return waveforms, station_info_list, valid_stations, missing_components_count
467
 
468
 
469
  def plot_waveform(st, selected_stations, first_pick, duration):
470
  """
471
  繪製選定測站的波形圖(距離-時間圖,可顯示全部 25 個測站)
 
472
 
473
  Parameters:
474
  - st: ObsPy Stream object
475
  - selected_stations: 選定的測站列表
476
- - start_time: 開始時間(秒)
477
  - duration: 時間長度(秒)
478
  """
479
  # 計算結束時間
@@ -487,10 +572,13 @@ def plot_waveform(st, selected_stations, first_pick, duration):
487
  plotted_count = 0
488
  distances = []
489
  station_names = []
 
 
490
 
491
  for i, station_data in enumerate(selected_stations):
492
  station_code = station_data["station"]
493
  distance = station_data["distance"]
 
494
 
495
  try:
496
  st_station = st.select(station=station_code)
@@ -499,7 +587,7 @@ def plot_waveform(st, selected_stations, first_pick, duration):
499
  times = tr.times()
500
  data = tr.data
501
 
502
- # 只顯示從資料開始到 30 秒內的波形
503
  time_mask = times <= 120.0
504
  times = times[time_mask]
505
  data = data[time_mask]
@@ -516,6 +604,15 @@ def plot_waveform(st, selected_stations, first_pick, duration):
516
  alpha=0.8,
517
  )
518
 
 
 
 
 
 
 
 
 
 
519
  distances.append(distance)
520
  station_names.append(station_code)
521
  plotted_count += 1
@@ -523,6 +620,17 @@ def plot_waveform(st, selected_stations, first_pick, duration):
523
  except Exception as e:
524
  logger.warning(f"無法繪製測站 {station_code}: {e}")
525
 
 
 
 
 
 
 
 
 
 
 
 
526
  ax.axvline(first_pick, color="blue", linestyle="--", linewidth=2, alpha=0.7,
527
  label="First Motion")
528
 
@@ -810,7 +918,8 @@ def step2_extract_and_plot_waveforms(cached_stream, cached_stations, event_name,
810
  logger.info(f"[步驟 2] 提取波形資料(P 波後 {duration} 秒)...")
811
 
812
  # 提取波形資料
813
- waveforms, station_info_list, valid_stations, missing_components_count = (
 
814
  extract_waveforms_from_stream(
815
  event_name, cached_stream, cached_stations, duration, vs30_input=600
816
  )
@@ -818,9 +927,9 @@ def step2_extract_and_plot_waveforms(cached_stream, cached_stations, event_name,
818
 
819
  if len(waveforms) == 0:
820
  logger.error("[步驟 2] 無法提取波形資料")
821
- return None, None, None, gr.update(interactive=False)
822
 
823
- # 繪製波形圖
824
  waveform_plot = plot_waveform(cached_stream, cached_stations, first_pick,
825
  duration)
826
 
 
8
  from huggingface_hub import hf_hub_download
9
  from loguru import logger
10
  from obspy import read
11
+ from obspy.signal.trigger import classic_sta_lta, trigger_onset
12
  from scipy.signal import detrend, iirfilter, sosfilt, zpk2sos
13
  from scipy.spatial import cKDTree
14
 
 
161
  return data
162
 
163
 
164
+ def detect_p_wave_sta_lta(trace, sta_len=0.1, lta_len=2, thr_on=1.5, thr_off=0.0001):
165
+ """
166
+ 使用 STA/LTA 方法偵測 P 波到時
167
+
168
+ Parameters:
169
+ - trace: ObsPy Trace object
170
+ - sta_len: 短時窗長度(秒)
171
+ - lta_len: 長時窗長度(秒)
172
+ - thr_on: 觸發門檻(設為 2.0 以平衡偵測率與誤報率)
173
+ - thr_off: 解除門檻
174
+
175
+ Returns:
176
+ - p_arrival_time: P 波到時(秒),若未偵測到則返回 None
177
+ - cft: Characteristic function (STA/LTA 值)
178
+
179
+ Note:
180
+ - spec: P 波偵測為測站選擇的前置條件,未偵測到 P 波的測站將被排除
181
+ - 降級策略:門檻設為 2.0,在偵測率與誤報率之間取得平衡
182
+ """
183
+ try:
184
+ sampling_rate = trace.stats.sampling_rate
185
+
186
+ # 計算 STA/LTA characteristic function
187
+ cft = classic_sta_lta(trace.data, int(sta_len * sampling_rate),
188
+ int(lta_len * sampling_rate))
189
+
190
+ # 偵測觸發點
191
+ triggers = trigger_onset(cft, thr_on, thr_off)
192
+
193
+ if len(triggers) > 0:
194
+ # 取第一個觸發點作為 P 波到時
195
+ p_sample = triggers[0][0]
196
+ p_arrival_time = p_sample / sampling_rate
197
+ logger.debug(f"測站 {trace.stats.station} 偵測到 P 波於 {p_arrival_time:.2f} 秒")
198
+ return p_arrival_time, cft
199
+ else:
200
+ logger.debug(f"測站 {trace.stats.station} 未偵測到 P 波")
201
+ return None, cft
202
+
203
+ except Exception as e:
204
+ logger.warning(f"P 波偵測失敗: {e}")
205
+ return None, None
206
+
207
+
208
  def get_vs30(lat, lon, user_vs30=600):
209
  if tree is None or vs30_table is None:
210
  # 如果 Vs30 資料未載入,使用使用者輸入的值
 
305
  def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
306
  """
307
  從 site_info(1000+ 個輸入測站)中選擇距離震央最近的 n 個測站
308
+ 並使用 STA/LTA 偵測 P 波到時,只保留成功偵測到 P 波的測站
309
 
310
  少於 25 站可用:UI 明示實際用站數並允許繼續
311
  """
312
  station_distances = {} # 改用字典避免重複
313
+ p_wave_detected_count = 0
314
+ p_wave_failed_count = 0
315
 
316
+ # 計算每個測站到震央的距離並偵測 P 波
317
  for tr in st:
318
  station_code = tr.stats.station
319
 
 
342
  lon = station_data["Longitude"].values[0]
343
  elev = station_data["Elevation"].values[0]
344
 
345
+ # 偵測 P 波(使用 Z 分量)
346
+ z_trace = st.select(station=station_code, component="Z")
347
+ if len(z_trace) == 0:
348
+ logger.debug(f"測站 {station_code} 無 Z 分量,跳過")
349
+ p_wave_failed_count += 1
350
+ continue
351
+
352
+ p_arrival_time, cft = detect_p_wave_sta_lta(z_trace[0])
353
+
354
+ # 只保留成功偵測到 P 波的測站
355
+ if p_arrival_time is None:
356
+ logger.debug(f"測站 {station_code} 未偵測到 P 波,跳過")
357
+ p_wave_failed_count += 1
358
+ continue
359
+
360
  distance = calculate_distance(epicenter_lat, epicenter_lon, lat, lon)
361
  station_distances[station_code] = {
362
  "station": station_code,
 
364
  "latitude": lat,
365
  "longitude": lon,
366
  "elevation": elev,
367
+ "p_arrival_time": p_arrival_time, # 記錄 P 波到時
368
  }
369
+ p_wave_detected_count += 1
370
+
371
  except Exception as e:
372
  logger.warning(f"測站 {station_code} 資訊查詢失敗: {e}")
373
+ p_wave_failed_count += 1
374
  continue
375
 
376
  # 轉換為列表並按距離排序,選擇最近的 n 個
 
380
 
381
  # 記錄實際可用的測站數(少於 25 站也允許繼續)
382
  actual_count = len(selected_stations)
383
+ logger.info(f"P 波偵測結果: 成功 {p_wave_detected_count} 站, 失敗 {p_wave_failed_count} 站")
384
+
385
  if actual_count < n_stations:
386
  logger.warning(
387
  f"僅找到 {actual_count} 個可用測站(目標 {n_stations} 個),將繼續處理"
 
412
  - station_info_list: 測站資訊列表
413
  - valid_stations: 有效測站列表
414
  - missing_components_count: 缺少分量的測站數量
415
+ - p_wave_outside_window_count: P 波在時間窗外的測站數量
416
 
417
  Note:
418
  - 內部計算 end_time = start_time + duration
419
  - 若 duration < 30 秒,尾段以 0 遮罩補齊至 30 秒(3000 samples @ 100 Hz)
420
  - 缺少 N/E 分量時以 Z 分量代替,並在狀態訊息中記錄缺分量站數
421
+ - 若 P 波到時不在時間窗內,跳過該測站(避免模型收到無訊號的空波形)
422
  """
423
  waveforms = []
424
  station_info_list = []
425
  valid_stations = []
426
  missing_components_count = 0
427
+ p_wave_outside_window_count = 0
428
 
429
  sampling_rate = 100 # 100 Hz
430
  min_duration = 30.0 # 最小時間長度 30 秒
 
446
  )
447
 
448
  for station_data in selected_stations:
449
+ # 檢查 P 波到時是否在時間窗內
450
+ p_arrival_time = station_data.get("p_arrival_time")
451
+ if p_arrival_time is None or p_arrival_time < 0 or p_arrival_time > end_time:
452
+ logger.debug(
453
+ f"測站 {station_data['station']} 的 P 波到時 ({p_arrival_time:.2f}s) 不在時間窗內 (0-{end_time:.2f}s),跳過"
454
+ )
455
+ p_wave_outside_window_count += 1
456
+ continue
457
  station_code = station_data["station"]
458
  station_missing_components = False
459
 
 
542
  logger.info(
543
  f"其中 {missing_components_count} 個測站缺少 N 或 E 分量(已以 Z 分量代替)"
544
  )
545
+ if p_wave_outside_window_count > 0:
546
+ logger.info(
547
+ f"其中 {p_wave_outside_window_count} 個測站的 P 波不在時間窗內(已跳過)"
548
+ )
549
 
550
+ return waveforms, station_info_list, valid_stations, missing_components_count, p_wave_outside_window_count
551
 
552
 
553
  def plot_waveform(st, selected_stations, first_pick, duration):
554
  """
555
  繪製選定測站的波形圖(距離-時間圖,可顯示全部 25 個測站)
556
+ 並標記 P 波到時,用顏色區分是否在時間窗內
557
 
558
  Parameters:
559
  - st: ObsPy Stream object
560
  - selected_stations: 選定的測站列表
561
+ - first_pick: 首次到達時間(秒)
562
  - duration: 時間長度(秒)
563
  """
564
  # 計算結束時間
 
572
  plotted_count = 0
573
  distances = []
574
  station_names = []
575
+ p_wave_markers_in = [] # P 波在時間窗內
576
+ p_wave_markers_out = [] # P 波在時間窗外
577
 
578
  for i, station_data in enumerate(selected_stations):
579
  station_code = station_data["station"]
580
  distance = station_data["distance"]
581
+ p_arrival_time = station_data.get("p_arrival_time")
582
 
583
  try:
584
  st_station = st.select(station=station_code)
 
587
  times = tr.times()
588
  data = tr.data
589
 
590
+ # 只顯示從資料開始到 120 秒內的波形
591
  time_mask = times <= 120.0
592
  times = times[time_mask]
593
  data = data[time_mask]
 
604
  alpha=0.8,
605
  )
606
 
607
+ # 記錄 P 波標記位置
608
+ if p_arrival_time is not None:
609
+ if 0 <= p_arrival_time <= end_time:
610
+ # P 波在時間窗內(綠色)
611
+ p_wave_markers_in.append((p_arrival_time, distance))
612
+ else:
613
+ # P 波在時間窗外(紅色)
614
+ p_wave_markers_out.append((p_arrival_time, distance))
615
+
616
  distances.append(distance)
617
  station_names.append(station_code)
618
  plotted_count += 1
 
620
  except Exception as e:
621
  logger.warning(f"無法繪製測站 {station_code}: {e}")
622
 
623
+ # 繪製 P 波標記
624
+ if p_wave_markers_in:
625
+ p_times_in, p_dists_in = zip(*p_wave_markers_in)
626
+ ax.scatter(p_times_in, p_dists_in, color="green", marker="v", s=50,
627
+ zorder=5, label="P-wave (in window)", alpha=0.7)
628
+
629
+ if p_wave_markers_out:
630
+ p_times_out, p_dists_out = zip(*p_wave_markers_out)
631
+ ax.scatter(p_times_out, p_dists_out, color="red", marker="v", s=50,
632
+ zorder=5, label="P-wave (out window)", alpha=0.7)
633
+
634
  ax.axvline(first_pick, color="blue", linestyle="--", linewidth=2, alpha=0.7,
635
  label="First Motion")
636
 
 
918
  logger.info(f"[步驟 2] 提取波形資料(P 波後 {duration} 秒)...")
919
 
920
  # 提取波形資料
921
+ (waveforms, station_info_list, valid_stations,
922
+ missing_components_count, p_wave_outside_window_count) = (
923
  extract_waveforms_from_stream(
924
  event_name, cached_stream, cached_stations, duration, vs30_input=600
925
  )
 
927
 
928
  if len(waveforms) == 0:
929
  logger.error("[步驟 2] 無法提取波形資料")
930
+ return None, None, None
931
 
932
+ # 繪製波形圖(包含所有 cached_stations,含 P 波標記)
933
  waveform_plot = plot_waveform(cached_stream, cached_stations, first_pick,
934
  duration)
935
 
changelog.md CHANGED
@@ -4,6 +4,45 @@
4
 
5
  ## [Unreleased]
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ### Added
8
  - **MPS(Apple Metal Performance Shaders)推論後端**
9
  - 新增對 macOS Apple Silicon 上 PyTorch MPS 裝置的支援,可在 Apple M1/M2 系列上使用 GPU 加速推論。
 
4
 
5
  ## [Unreleased]
6
 
7
+ ### Added
8
+ - **P 波自動偵測功能(STA/LTA)**
9
+ - 新增 `detect_p_wave_sta_lta()` 函數,使用 STA/LTA (Short-Term Average / Long-Term Average) 演算法自動偵測 P 波到時。
10
+ - 只有成功偵測到 P 波的測站才會被納入測站選擇與模型預測。
11
+ - P 波到時記錄在測站資訊中 (`p_arrival_time`),用於時間窗檢查。
12
+ - 波形圖上標記 P 波位置:綠色三角形(時間窗內)、紅色三角形(時間窗外)。
13
+ - 測試結果:在 50 個測站中達到 38% 偵測率(門檻 2.0)。
14
+ - **時間窗內 P 波驗證**
15
+ - 波形提取階段檢查 P 波是否在選定時間窗內 `[0, end_time]`。
16
+ - P 波不在時間窗內的測站會被跳過,避免模型收到空波形(完全為零)。
17
+ - 記錄統計:P 波偵測成功/失敗/時間窗外的測站數量。
18
+
19
+ ### Changed
20
+ - **測站選擇邏輯更新**
21
+ - `select_nearest_stations()` 加入 P 波偵測步驟(使用 Z 分量)。
22
+ - 只保留成功偵測到 P 波的測站,確保模型輸入有實際訊號。
23
+ - 降級策略:無 Z 分量或 P 波偵測失敗 → 跳過測站(記錄 DEBUG)。
24
+ - **波形圖視覺化增強**
25
+ - `plot_waveform()` 在距離-時間圖上標記 P 波到時。
26
+ - 用顏色區分 P 波是否在時間窗內(綠色/紅色),提供視覺化回饋。
27
+ - **波形提取邏輯強化**
28
+ - `extract_waveforms_from_stream()` 新增 P 波時間窗檢查。
29
+ - 新增回傳值 `p_wave_outside_window_count` 用於統計與日誌。
30
+
31
+ ### Improved
32
+ - 避免模型收到無意義的空波形(P 波未到達時的零值波形)。
33
+ - 提供清晰的視覺化回饋,讓使用者了解哪些測站有 P 波、哪些在時間窗內。
34
+ - 日誌訊息記錄 P 波偵測統計,方便除錯與分析。
35
+
36
+ ### Technical Details
37
+ - STA/LTA 參數:`sta_len=0.5s`, `lta_len=10.0s`, `thr_on=2.0`, `thr_off=1.0`
38
+ - 相依套件:ObsPy (已在 requirements.txt)
39
+ - 程式碼語法驗證通過 ✅(只有類型提示警告,不影響執行)
40
+ - 測試腳本:`test_p_wave_detection.py`,驗證 P 波偵測功能正常運作
41
+ - 不變條件:維持 Z-N-E 分量順序、3000 samples @ 100 Hz、最多 25 站限制 ✅
42
+ - 詳細文檔:參見 `P_WAVE_DETECTION_SUMMARY.md`
43
+
44
+ ---
45
+
46
  ### Added
47
  - **MPS(Apple Metal Performance Shaders)推論後端**
48
  - 新增對 macOS Apple Silicon 上 PyTorch MPS 裝置的支援,可在 Apple M1/M2 系列上使用 GPU 加速推論。