jimmy60504 commited on
Commit
fb80c1a
·
1 Parent(s): 285c304

add Docker support with Dockerfile and scripts; implement error handling for station data loading and validation

Browse files
Files changed (4) hide show
  1. .dockerignore +17 -0
  2. Dockerfile +24 -0
  3. app.py +162 -50
  4. image_python.sh +10 -0
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ```text
3
+ # Single project (現況)
4
+ # 注意:app.py 和 requirements.txt 需要被包含在 Docker image 中
5
+ intensityMap.html
6
+ # station/ 和 waveform/ 會在運行時從外部掛載
7
+ station/
8
+ waveform/
9
+ ground_truth/
10
+ ```
11
+
12
+ **Structure Decision**: 維持單一專案根目錄佈局,Gradio 介面在 `app.py`;地圖以 HTML/folium/Gradio HTML 容器渲染。
13
+
14
+ ## Complexity Tracking
15
+
16
+ N/A(無憲章違反項目需特別豁免)。
17
+
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ FROM python:3.10-slim
3
+
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ GRADIO_SERVER_PORT=7860 \
8
+ GRADIO_SERVER_NAME=0.0.0.0
9
+
10
+ # 基本系統套件與科學計算依賴(netCDF4/scipy)
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ build-essential \
13
+ libhdf5-dev \
14
+ libnetcdf-dev \
15
+ libopenblas-dev \
16
+ liblapack-dev \
17
+ git \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ COPY requirements.txt /tmp/
21
+ RUN pip install --upgrade pip && pip install -r /tmp/requirements.txt
22
+
23
+ EXPOSE 7860
24
+
app.py CHANGED
@@ -48,26 +48,47 @@ except Exception as e:
48
  logger.warning(f"Vs30 資料載入失敗: {e}")
49
  logger.warning("將使用預設 Vs30 值 (600 m/s)")
50
 
51
- # 載入目標測站
52
- target_file = "station/eew_target.csv"
53
- try:
54
- logger.info(f"載入 {target_file}...")
55
- target_df = pd.read_csv(target_file)
56
- target_dict = target_df.to_dict(orient="records")
57
- logger.info(f"{target_file} 載入完成")
58
- except FileNotFoundError:
59
- logger.error(f"{target_file} 找不到")
60
-
61
  # 載入測站資訊(輸入測站,1000+ 個)
62
  site_info_file = "station/site_info.csv"
 
63
  try:
64
  logger.info(f"載入 {site_info_file}...")
65
  site_info = pd.read_csv(site_info_file)
 
 
 
 
 
 
 
 
66
  # 只保留唯一的測站(去除重複的分量)
67
  site_info = site_info.drop_duplicates(subset=['Station']).reset_index(drop=True)
68
  logger.info(f"{site_info_file} 載入完成,共 {len(site_info)} 個測站")
69
  except FileNotFoundError:
70
- logger.warning(f"{site_info_file} 找不到")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  # 預設地震事件
73
  EARTHQUAKE_EVENTS = {
@@ -417,7 +438,11 @@ def calculate_distance(lat1, lon1, lat2, lon2):
417
 
418
 
419
  def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
420
- """從 site_info(1000+ 個輸入測站)中選擇距離震央最近的 n 個測站"""
 
 
 
 
421
  station_distances = {} # 改用字典避免重複
422
 
423
  # 計算每個測站到震央的距離
@@ -428,12 +453,19 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
428
  if station_code in station_distances:
429
  continue
430
 
431
- # 從 site_info 中查詢測站位置
432
  try:
433
  station_data = site_info[site_info["Station"] == station_code]
434
  if len(station_data) == 0:
435
  continue
436
 
 
 
 
 
 
 
 
437
  lat = station_data["Latitude"].values[0]
438
  lon = station_data["Longitude"].values[0]
439
  elev = station_data["Elevation"].values[0]
@@ -447,7 +479,7 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
447
  "elevation": elev
448
  }
449
  except Exception as e:
450
- logger.warning(f"測站 {station_code} 資訊查詢失敗: {e}")
451
  continue
452
 
453
  # 轉換為列表並按距離排序,選擇最近的 n 個
@@ -455,23 +487,62 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
455
  station_list.sort(key=lambda x: x["distance"])
456
  selected_stations = station_list[:n_stations]
457
 
458
- logger.info(f"從 {len(station_list)} 個輸入測站中選擇了最近的 {len(selected_stations)} 個")
 
 
 
 
 
 
459
  return selected_stations
460
 
461
 
462
- def extract_waveforms_from_stream(st, selected_stations, start_time, end_time, vs30_input):
463
- """從 Stream 中提取選定測站的波形資料"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  waveforms = []
465
  station_info_list = []
466
  valid_stations = []
 
 
 
 
 
 
 
 
467
 
468
- sampling_rate = 100 # 假設 100 Hz
469
  start_idx = int(start_time * sampling_rate)
470
  end_idx = int(end_time * sampling_rate)
471
- target_length = 3000
 
 
 
 
 
472
 
473
  for station_data in selected_stations:
474
  station_code = station_data["station"]
 
475
 
476
  try:
477
  # 選擇該測站的所有分量
@@ -485,31 +556,43 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, end_time, v
485
  n_trace = st_station.select(component="N") or st_station.select(component="1")
486
  e_trace = st_station.select(component="E") or st_station.select(component="2")
487
 
488
- # 如果沒有三分量,使用 Z 分量重複
489
  if len(z_trace) > 0:
490
  z_data = z_trace[0].data[start_idx:end_idx]
491
  else:
492
  continue
493
 
 
494
  if len(n_trace) > 0:
495
  n_data = n_trace[0].data[start_idx:end_idx]
496
  else:
497
  n_data = z_data.copy()
 
 
498
 
 
499
  if len(e_trace) > 0:
500
  e_data = e_trace[0].data[start_idx:end_idx]
501
  else:
502
  e_data = z_data.copy()
 
 
 
 
 
 
503
 
504
  # 訊號處理
505
  z_data = signal_processing(z_data)
506
  n_data = signal_processing(n_data)
507
  e_data = signal_processing(e_data)
508
 
509
- # 先創建全零陣列 (3000, 3)
 
510
  waveform_3c = np.zeros((target_length, 3))
511
 
512
- # 填入實際資料(自動處理長度不足或過長的情況)
 
513
  z_len = min(len(z_data), target_length)
514
  n_len = min(len(n_data), target_length)
515
  e_len = min(len(e_data), target_length)
@@ -535,11 +618,25 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, end_time, v
535
  continue
536
 
537
  logger.info(f"成功提取 {len(waveforms)} 個測站的波形")
538
- return waveforms, station_info_list, valid_stations
 
 
 
539
 
540
 
541
- def plot_waveform(st, selected_stations, start_time, end_time):
542
- """繪製選定測站的���形圖(距離-時間圖,可顯示全部 25 個測站)"""
 
 
 
 
 
 
 
 
 
 
 
543
  fig, ax = plt.subplots(figsize=(14, 10))
544
 
545
  # 設定振幅縮放比例(避免波形重疊)
@@ -628,13 +725,13 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
628
  import folium
629
  from folium import plugins
630
 
631
- # 創建地圖,中心點設在台灣中心,設定地圖尺寸
632
  m = folium.Map(
633
  location=[23.5, 121],
634
  zoom_start=7,
635
  tiles='OpenStreetMap',
636
  width='100%',
637
- height='800px' # 設定固定高度 800,與 Ground Truth 圖片匹配
638
  )
639
 
640
  # 如果有震央位置,標記震央
@@ -737,7 +834,11 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
737
 
738
 
739
  def load_ground_truth_image(event_name):
740
- """從 ground_truth 資料夾載入對應的 Ground Truth 圖片"""
 
 
 
 
741
  import os
742
 
743
  # 根據事件名稱找對應的圖片
@@ -755,7 +856,8 @@ def load_ground_truth_image(event_name):
755
  logger.info(f"載入 Ground Truth 圖片: {image_path}")
756
  return image_path
757
 
758
- logger.warning(f"找不到 Ground Truth 圖片: {event_date}")
 
759
  return None
760
 
761
 
@@ -774,13 +876,13 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
774
  import folium
775
  from folium import plugins
776
 
777
- # 創建地圖,中心點設在震央
778
  m = folium.Map(
779
  location=[epicenter_lat, epicenter_lon],
780
  zoom_start=8,
781
  tiles='OpenStreetMap',
782
  width='100%',
783
- height='800px'
784
  )
785
 
786
  # 建立被選中測站的 set(用於快速查詢)
@@ -895,7 +997,7 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
895
  def load_and_display_waveform(event_name, start_time, duration, epicenter_lon, epicenter_lat):
896
  """載入並顯示波形,讓使用者確認範圍"""
897
  try:
898
- # 計算結束時間
899
  end_time = start_time + duration
900
 
901
  # 1. 載入完整的 mseed 檔案
@@ -908,21 +1010,24 @@ def load_and_display_waveform(event_name, start_time, duration, epicenter_lon, e
908
  selected_stations = select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25)
909
 
910
  if len(selected_stations) == 0:
911
- return None, "錯誤:找不到有效的測站資料", gr.update(interactive=False)
912
 
913
- # 3. 繪製波形
914
- waveform_plot = plot_waveform(st, selected_stations, start_time, end_time)
915
 
916
  # 4. 創建輸入測站地圖
917
  station_map = create_input_station_map(selected_stations, epicenter_lat, epicenter_lon)
918
  station_map_html = station_map._repr_html_()
919
 
 
920
  info_text = f"✅ 已載入波形資料\n"
921
  info_text += f"開始時間: {start_time:.1f} 秒\n"
922
  info_text += f"時間長度: {duration:.1f} 秒 ({start_time:.1f} - {end_time:.1f})\n"
923
  info_text += f"震央位置: ({epicenter_lon:.4f}, {epicenter_lat:.4f})\n"
924
- info_text += f"選擇了 {len(selected_stations)} 個最近的測站\n"
925
- info_text += f"請確認波形範圍後,點擊「執行預測」按鈕"
 
 
926
 
927
  logger.info("波形載入完成")
928
  return station_map_html, waveform_plot, info_text, gr.update(interactive=True)
@@ -935,9 +1040,13 @@ def load_and_display_waveform(event_name, start_time, duration, epicenter_lon, e
935
 
936
 
937
  def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter_lat):
938
- """執行震度預測"""
 
 
 
 
939
  try:
940
- # 計算結束時間
941
  end_time = start_time + duration
942
 
943
  # 1. 載入完整的 mseed 檔案
@@ -953,9 +1062,9 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
953
  return None, None, "錯誤:找不到有效的測站資料"
954
 
955
  # 3. 從選定的測站提取波形(vs30_input 使用預設值 600,會被資料庫值覆蓋)
956
- logger.info(f"提取波形資料(時間範圍: {start_time}-{end_time} 秒)...")
957
- waveforms, station_info_list, valid_stations = extract_waveforms_from_stream(
958
- st, selected_stations, start_time, end_time, vs30_input=600
959
  )
960
 
961
  if len(waveforms) == 0:
@@ -970,16 +1079,16 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
970
  waveform_padded[i] = waveforms[i]
971
  station_info_padded[i] = station_info_list[i]
972
 
973
- # 5. 準備所有目標測站資訊(分批處理)
974
  all_pga_list = []
975
  all_target_names = []
976
 
977
- # 計算需要分幾批(每批 25 個測站)
978
  batch_size = 25
979
  total_targets = len(target_dict)
980
  num_batches = (total_targets + batch_size - 1) // batch_size
981
 
982
- logger.info(f"開始分批預測 {total_targets} 個目標測站(共 {num_batches} 批)...")
983
 
984
  for batch_idx in range(num_batches):
985
  start_idx = batch_idx * batch_size
@@ -1021,28 +1130,31 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
1021
  all_pga_list.extend(batch_pga[:len(target_names)])
1022
  all_target_names.extend(target_names)
1023
 
1024
- logger.info(f"完成所有 {len(all_target_names)} 個測站的預測!")
1025
  pga_list = all_pga_list
1026
  target_names = all_target_names
1027
 
1028
- # 8. 繪製互動式地圖
1029
  intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
1030
  map_html = intensity_map._repr_html_()
1031
 
1032
- # 9. 載入 Ground Truth 圖片
1033
  ground_truth_path = load_ground_truth_image(event_name)
1034
 
1035
- # 10. 統計資訊
1036
  max_intensity = max([calculate_intensity(pga, label=True) for pga in pga_list])
1037
  stats = f"✅ 預測完成!\n"
1038
  stats += f"開始時間: {start_time:.1f} 秒\n"
1039
  stats += f"時間長度: {duration:.1f} 秒 ({start_time:.1f} - {end_time:.1f})\n"
1040
  stats += f"震央位置: ({epicenter_lon:.4f}, {epicenter_lat:.4f})\n"
1041
  stats += f"使用測站數: {len(waveforms)} / 25\n"
1042
- stats += f"預測目標點數: {len(all_target_names)}\n"
 
 
1043
  stats += f"預測最大震度: {max_intensity}"
1044
 
1045
  logger.info("預測完成!")
 
1046
  return ground_truth_path, map_html, stats
1047
 
1048
  except Exception as e:
 
48
  logger.warning(f"Vs30 資料載入失敗: {e}")
49
  logger.warning("將使用預設 Vs30 值 (600 m/s)")
50
 
 
 
 
 
 
 
 
 
 
 
51
  # 載入測站資訊(輸入測站,1000+ 個)
52
  site_info_file = "station/site_info.csv"
53
+ site_info = None
54
  try:
55
  logger.info(f"載入 {site_info_file}...")
56
  site_info = pd.read_csv(site_info_file)
57
+
58
+ # [T012] 驗證 site_info.csv 必要欄位
59
+ required_site_fields = ["Station", "Latitude", "Longitude", "Elevation"]
60
+ missing_site_fields = [f for f in required_site_fields if f not in site_info.columns]
61
+ if missing_site_fields:
62
+ logger.error(f"[T012] {site_info_file} 缺少必要欄位: {missing_site_fields}")
63
+ raise ValueError(f"site_info.csv 缺少必要欄位: {missing_site_fields}")
64
+
65
  # 只保留唯一的測站(去除重複的分量)
66
  site_info = site_info.drop_duplicates(subset=['Station']).reset_index(drop=True)
67
  logger.info(f"{site_info_file} 載入完成,共 {len(site_info)} 個測站")
68
  except FileNotFoundError:
69
+ logger.warning(f"[T012] {site_info_file} 找不到")
70
+ except Exception as e:
71
+ logger.error(f"[T012] {site_info_file} 載入失敗: {e}")
72
+
73
+ # 載入目標測站
74
+ target_file = "station/eew_target.csv"
75
+ try:
76
+ logger.info(f"載入 {target_file}...")
77
+ target_df = pd.read_csv(target_file)
78
+
79
+ # [T012] 驗證 eew_target.csv 必要欄位
80
+ required_target_fields = ["station", "latitude", "longitude", "elevation"]
81
+ missing_target_fields = [f for f in required_target_fields if f not in target_df.columns]
82
+ if missing_target_fields:
83
+ logger.error(f"[T012] {target_file} 缺少必要欄位: {missing_target_fields}")
84
+ raise ValueError(f"eew_target.csv 缺少必要欄位: {missing_target_fields}")
85
+
86
+ target_dict = target_df.to_dict(orient="records")
87
+ logger.info(f"{target_file} 載入完成(共 {len(target_dict)} 個目標點)")
88
+ except FileNotFoundError:
89
+ logger.error(f"[T012] {target_file} 找不到")
90
+ except Exception as e:
91
+ logger.error(f"[T012] {target_file} 載入失敗: {e}")
92
 
93
  # 預設地震事件
94
  EARTHQUAKE_EVENTS = {
 
438
 
439
 
440
  def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
441
+ """
442
+ 從 site_info(1000+ 個輸入測站)中選擇距離震央最近的 n 個測站
443
+
444
+ [T013] 少於 25 站可用:UI 明示實際用站數並允許繼續
445
+ """
446
  station_distances = {} # 改用字典避免重複
447
 
448
  # 計算每個測站到震央的距離
 
453
  if station_code in station_distances:
454
  continue
455
 
456
+ # [T012] 從 site_info 中查詢測站位置(處理缺漏欄位)
457
  try:
458
  station_data = site_info[site_info["Station"] == station_code]
459
  if len(station_data) == 0:
460
  continue
461
 
462
+ # [T012] 驗證必要欄位存在
463
+ required_fields = ["Latitude", "Longitude", "Elevation"]
464
+ missing_fields = [f for f in required_fields if f not in station_data.columns]
465
+ if missing_fields:
466
+ logger.warning(f"[T012] 測站 {station_code} 缺少必要欄位: {missing_fields},跳過")
467
+ continue
468
+
469
  lat = station_data["Latitude"].values[0]
470
  lon = station_data["Longitude"].values[0]
471
  elev = station_data["Elevation"].values[0]
 
479
  "elevation": elev
480
  }
481
  except Exception as e:
482
+ logger.warning(f"[T012] 測站 {station_code} 資訊查詢失敗: {e}")
483
  continue
484
 
485
  # 轉換為列表並按距離排序,選擇最近的 n 個
 
487
  station_list.sort(key=lambda x: x["distance"])
488
  selected_stations = station_list[:n_stations]
489
 
490
+ # [T013] 記錄實際可用的測站數(少於 25 站也允許繼續)
491
+ actual_count = len(selected_stations)
492
+ if actual_count < n_stations:
493
+ logger.warning(f"[T013] 僅找到 {actual_count} 個可用測站(目標 {n_stations} 個),將繼續處理")
494
+ else:
495
+ logger.info(f"從 {len(station_list)} 個輸入測站中選擇了最近的 {actual_count} 個")
496
+
497
  return selected_stations
498
 
499
 
500
+ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, vs30_input):
501
+ """
502
+ 從 Stream 中提取選定測站的波形資料
503
+
504
+ Parameters:
505
+ - st: ObsPy Stream object
506
+ - selected_stations: 選定的測站列表
507
+ - start_time: 開始時間(秒)
508
+ - duration: 時間長度(秒)
509
+ - vs30_input: Vs30 預設值
510
+
511
+ Returns:
512
+ - waveforms: 波形資料列表
513
+ - station_info_list: 測站資訊列表
514
+ - valid_stations: 有效測站列表
515
+ - missing_components_count: 缺少分量的測站數量
516
+
517
+ Note:
518
+ - [T004] 內部計算 end_time = start_time + duration
519
+ - [T004] 若 duration < 30 秒,尾段以 0 遮罩補齊至 30 秒(3000 samples @ 100 Hz)
520
+ - [T005] 缺少 N/E 分量時以 Z 分量代替,並在狀態訊息中記錄缺分量站數
521
+ """
522
  waveforms = []
523
  station_info_list = []
524
  valid_stations = []
525
+ missing_components_count = 0 # [T005] 記錄缺少分量的測站數
526
+
527
+ sampling_rate = 100 # 100 Hz
528
+ min_duration = 30.0 # 最小時間長度 30 秒
529
+ target_length = 3000 # 30 秒 @ 100 Hz = 3000 samples
530
+
531
+ # [T004] 內部計算 end_time(接受 start/duration 參數)
532
+ end_time = start_time + duration
533
 
 
534
  start_idx = int(start_time * sampling_rate)
535
  end_idx = int(end_time * sampling_rate)
536
+ actual_samples = end_idx - start_idx
537
+
538
+ # [T004] 檢查是否需要零填充:長度不足 30 秒時尾段以 0 遮罩補齊
539
+ needs_padding = duration < min_duration
540
+ if needs_padding:
541
+ logger.info(f"[T004] 時間長度 {duration} 秒 < 30 秒,將以 0 遮罩補齊至 {min_duration} 秒")
542
 
543
  for station_data in selected_stations:
544
  station_code = station_data["station"]
545
+ station_missing_components = False
546
 
547
  try:
548
  # 選擇該測站的所有分量
 
556
  n_trace = st_station.select(component="N") or st_station.select(component="1")
557
  e_trace = st_station.select(component="E") or st_station.select(component="2")
558
 
559
+ # 檢查 Z 分量(必須存在)
560
  if len(z_trace) > 0:
561
  z_data = z_trace[0].data[start_idx:end_idx]
562
  else:
563
  continue
564
 
565
+ # [T005] 檢查 N 分量(缺失時以 Z 代替)
566
  if len(n_trace) > 0:
567
  n_data = n_trace[0].data[start_idx:end_idx]
568
  else:
569
  n_data = z_data.copy()
570
+ station_missing_components = True
571
+ logger.debug(f"[T005] 測站 {station_code} 缺少 N 分量,以 Z 分量代替")
572
 
573
+ # [T005] 檢查 E 分量(缺失時以 Z 代替)
574
  if len(e_trace) > 0:
575
  e_data = e_trace[0].data[start_idx:end_idx]
576
  else:
577
  e_data = z_data.copy()
578
+ station_missing_components = True
579
+ logger.debug(f"[T005] 測站 {station_code} 缺少 E 分量,以 Z 分量代替")
580
+
581
+ # [T005] 記錄缺少分量的測站(將在狀態訊息中顯示)
582
+ if station_missing_components:
583
+ missing_components_count += 1
584
 
585
  # 訊號處理
586
  z_data = signal_processing(z_data)
587
  n_data = signal_processing(n_data)
588
  e_data = signal_processing(e_data)
589
 
590
+ # [T004] 創建全零陣列 (3000, 3) - 確保至少 30 秒長度
591
+ # 不足 30 秒時,尾段以 0 遮罩補齊
592
  waveform_3c = np.zeros((target_length, 3))
593
 
594
+ # [T004] 填入實際資料(處理長度不足或過長的情況)
595
+ # 若 duration < 30 秒,實際資料填在前面,後面自動保持為 0(零填充)
596
  z_len = min(len(z_data), target_length)
597
  n_len = min(len(n_data), target_length)
598
  e_len = min(len(e_data), target_length)
 
618
  continue
619
 
620
  logger.info(f"成功提取 {len(waveforms)} 個測站的波形")
621
+ if missing_components_count > 0:
622
+ logger.info(f"[T005] 其中 {missing_components_count} 個測站缺少 N 或 E 分量(已以 Z 分量代替)")
623
+
624
+ return waveforms, station_info_list, valid_stations, missing_components_count
625
 
626
 
627
+ def plot_waveform(st, selected_stations, start_time, duration):
628
+ """
629
+ 繪製選定測站的波形圖(距離-時間圖,可顯示全部 25 個測站)
630
+
631
+ Parameters:
632
+ - st: ObsPy Stream object
633
+ - selected_stations: 選定的測站列表
634
+ - start_time: 開始時間(秒)
635
+ - duration: 時間長度(秒)
636
+ """
637
+ # 計算結束時間
638
+ end_time = start_time + duration
639
+
640
  fig, ax = plt.subplots(figsize=(14, 10))
641
 
642
  # 設定振幅縮放比例(避免波形重疊)
 
725
  import folium
726
  from folium import plugins
727
 
728
+ # [T006] 創建地圖,設定固定高度 800(寬度 100%),與 Ground Truth 區塊一致
729
  m = folium.Map(
730
  location=[23.5, 121],
731
  zoom_start=7,
732
  tiles='OpenStreetMap',
733
  width='100%',
734
+ height='800px' # [T006] 固定高度 800px,不再依 Ground Truth 尺寸動態調整
735
  )
736
 
737
  # 如果有震央位置,標記震央
 
834
 
835
 
836
  def load_ground_truth_image(event_name):
837
+ """
838
+ 從 ground_truth 資料夾載入對應的 Ground Truth 圖片
839
+
840
+ [T011] Ground Truth 圖不存在時:顯示提示並用預設高度 800 呈現空白占位
841
+ """
842
  import os
843
 
844
  # 根據事件名稱找對應的圖片
 
856
  logger.info(f"載入 Ground Truth 圖片: {image_path}")
857
  return image_path
858
 
859
+ # [T011] 圖片不存在時記錄警告(UI 會用預設高度 800 顯示空白占位)
860
+ logger.warning(f"[T011] 找不到 Ground Truth 圖片: {event_date}(將顯示空白占位)")
861
  return None
862
 
863
 
 
876
  import folium
877
  from folium import plugins
878
 
879
+ # [T007] 創建地圖,設定固定高度 800(寬度 100%),與 Ground Truth 區塊一致
880
  m = folium.Map(
881
  location=[epicenter_lat, epicenter_lon],
882
  zoom_start=8,
883
  tiles='OpenStreetMap',
884
  width='100%',
885
+ height='800px' # [T007] 輸入測站地圖固定高度 800px
886
  )
887
 
888
  # 建立被選中測站的 set(用於快速查詢)
 
997
  def load_and_display_waveform(event_name, start_time, duration, epicenter_lon, epicenter_lat):
998
  """載入並顯示波形,讓使用者確認範圍"""
999
  try:
1000
+ # 計算結束時間(用於顯示資訊)
1001
  end_time = start_time + duration
1002
 
1003
  # 1. 載入完整的 mseed 檔案
 
1010
  selected_stations = select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25)
1011
 
1012
  if len(selected_stations) == 0:
1013
+ return None, None, "錯誤:找不到有效的測站資料", gr.update(interactive=False)
1014
 
1015
+ # 3. 繪製波形(傳入 start_time 和 duration)
1016
+ waveform_plot = plot_waveform(st, selected_stations, start_time, duration)
1017
 
1018
  # 4. 創建輸入測站地圖
1019
  station_map = create_input_station_map(selected_stations, epicenter_lat, epicenter_lon)
1020
  station_map_html = station_map._repr_html_()
1021
 
1022
+ # [T013] UI 明示實際用站數(少於 25 站時顯示警告)
1023
  info_text = f"✅ 已載入波形資料\n"
1024
  info_text += f"開始時間: {start_time:.1f} 秒\n"
1025
  info_text += f"時間長度: {duration:.1f} 秒 ({start_time:.1f} - {end_time:.1f})\n"
1026
  info_text += f"震央位置: ({epicenter_lon:.4f}, {epicenter_lat:.4f})\n"
1027
+ info_text += f"選擇了 {len(selected_stations)} 個最近的測站"
1028
+ if len(selected_stations) < 25:
1029
+ info_text += f" ⚠️(目標 25 個,實際 {len(selected_stations)} 個)"
1030
+ info_text += "\n請確認波形範圍後,點擊「執行預測」按鈕"
1031
 
1032
  logger.info("波形載入完成")
1033
  return station_map_html, waveform_plot, info_text, gr.update(interactive=True)
 
1040
 
1041
 
1042
  def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter_lat):
1043
+ """
1044
+ 執行震度預測
1045
+
1046
+ [T008] 介面改以 start+duration,使用固定高度 800 的地圖輸出(不傳遞 map_size_state)
1047
+ """
1048
  try:
1049
+ # [T008] 計算結束時間(內部處理)
1050
  end_time = start_time + duration
1051
 
1052
  # 1. 載入完整的 mseed 檔案
 
1062
  return None, None, "錯誤:找不到有效的測站資料"
1063
 
1064
  # 3. 從選定的測站提取波形(vs30_input 使用預設值 600,會被資料庫值覆蓋)
1065
+ logger.info(f"提取波形資料(時間範圍: {start_time} 秒起,持續 {duration} 秒)...")
1066
+ waveforms, station_info_list, valid_stations, missing_components_count = extract_waveforms_from_stream(
1067
+ st, selected_stations, start_time, duration, vs30_input=600
1068
  )
1069
 
1070
  if len(waveforms) == 0:
 
1079
  waveform_padded[i] = waveforms[i]
1080
  station_info_padded[i] = station_info_list[i]
1081
 
1082
+ # [T009] 5. 準備所有目標測站資訊(分批處理)- 保留分批處理流程
1083
  all_pga_list = []
1084
  all_target_names = []
1085
 
1086
+ # [T009] 計算需要分幾批(每批 25 個測站)
1087
  batch_size = 25
1088
  total_targets = len(target_dict)
1089
  num_batches = (total_targets + batch_size - 1) // batch_size
1090
 
1091
+ logger.info(f"[T009] 開始分批預測 {total_targets} 個目標測站(共 {num_batches} 批)...")
1092
 
1093
  for batch_idx in range(num_batches):
1094
  start_idx = batch_idx * batch_size
 
1130
  all_pga_list.extend(batch_pga[:len(target_names)])
1131
  all_target_names.extend(target_names)
1132
 
1133
+ logger.info(f"[T009] 完成所有 {len(all_target_names)} 個測站的預測!")
1134
  pga_list = all_pga_list
1135
  target_names = all_target_names
1136
 
1137
+ # [T008] 8. 繪製互動式地圖(使用固定高度 800)
1138
  intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
1139
  map_html = intensity_map._repr_html_()
1140
 
1141
+ # [T010] 9. 載入 Ground Truth 圖片(filepath;左側以 800 高顯示)
1142
  ground_truth_path = load_ground_truth_image(event_name)
1143
 
1144
+ # [T010] 10. 統計資訊(包含目標點數)
1145
  max_intensity = max([calculate_intensity(pga, label=True) for pga in pga_list])
1146
  stats = f"✅ 預測完成!\n"
1147
  stats += f"開始時間: {start_time:.1f} 秒\n"
1148
  stats += f"時間長度: {duration:.1f} 秒 ({start_time:.1f} - {end_time:.1f})\n"
1149
  stats += f"震央位置: ({epicenter_lon:.4f}, {epicenter_lat:.4f})\n"
1150
  stats += f"使用測站數: {len(waveforms)} / 25\n"
1151
+ if missing_components_count > 0:
1152
+ stats += f"⚠️ 缺少 N/E 分量測站數: {missing_components_count} (已以 Z 分量代替)\n"
1153
+ stats += f"預測目標點數: {len(all_target_names)}\n" # [T009] 統計資訊中補上目標點數
1154
  stats += f"預測最大震度: {max_intensity}"
1155
 
1156
  logger.info("預測完成!")
1157
+ # [T010] 回傳:Ground Truth 圖(filepath;左側 800 高)、預測地圖(HTML;右側 800 高)、統計資訊
1158
  return ground_truth_path, map_html, stats
1159
 
1160
  except Exception as e:
image_python.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ docker container rm ttsam-demo -f || true
2
+ docker run \
3
+ -it \
4
+ --rm \
5
+ --net host \
6
+ -v $(pwd):/workspace \
7
+ -w /workspace \
8
+ --name ttsam-demo \
9
+ ttsam-demo \
10
+ /usr/local/bin/python /workspace/app.py