Spaces:
Sleeping
Sleeping
Commit
·
fb80c1a
1
Parent(s):
285c304
add Docker support with Dockerfile and scripts; implement error handling for station data loading and validation
Browse files- .dockerignore +17 -0
- Dockerfile +24 -0
- app.py +162 -50
- 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
return selected_stations
|
| 460 |
|
| 461 |
|
| 462 |
-
def extract_waveforms_from_stream(st, selected_stations, start_time,
|
| 463 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
|
| 541 |
-
def plot_waveform(st, selected_stations, start_time,
|
| 542 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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' #
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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,
|
| 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)}
|
| 925 |
-
|
|
|
|
|
|
|
| 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}
|
| 957 |
-
waveforms, station_info_list, valid_stations = extract_waveforms_from_stream(
|
| 958 |
-
st, selected_stations, start_time,
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|