jimmy60504 commited on
Commit
0d38f7f
·
1 Parent(s): c2c2f17

refactor app.py to remove task identifiers from log messages for improved clarity

Browse files
.github/ISSUE_TEMPLATE/spec.yml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: 規格/使用者故事(Spec / User Story)
2
+ description: 以結構化方式提出/更新規格,讓未來程式碼生成能遵循
3
+ labels: [spec]
4
+ body:
5
+ - type: textarea
6
+ id: user-story
7
+ attributes:
8
+ label: 使用者故事(User Story)
9
+ description: 作為 ___,我想要 ___,以便 ___。
10
+ placeholder: >-
11
+ 作為地震分析人員,我想選擇事件、時間窗與震央位置並查看預測震度與實際觀測的對照,以便快速比對模型表現。
12
+ validations:
13
+ required: true
14
+
15
+ - type: textarea
16
+ id: motivation
17
+ attributes:
18
+ label: 動機/背景
19
+ placeholder: 為何需要這項規格?是否對齊目前 spec.md 的目標與不變條件?
20
+
21
+ - type: textarea
22
+ id: behavior
23
+ attributes:
24
+ label: 預期行為(Behavior)
25
+ description: 描述介面/流程/輸出行為,盡量具體
26
+ placeholder: >-
27
+ - UI:在右側新增一個開關,控制是否顯示觀測震度圖的透明疊加。
28
+ - 模型:不變。
29
+
30
+ - type: textarea
31
+ id: contract
32
+ attributes:
33
+ label: 契約(資料形狀/輸入輸出/限制)
34
+ description: 例如 tensor shape、欄位、預設值、邊界值
35
+ placeholder: >-
36
+ - waveform: (1, 25, 3000, 3)
37
+ - station/target: (1, 25, 4) -> [lat, lon, elev, vs30]
38
+
39
+ - type: textarea
40
+ id: edge-cases
41
+ attributes:
42
+ label: 邊界情境與降級策略(Edge cases & fallback)
43
+ placeholder: 站數不足、分量缺失、Vs30 載入失敗、duration < 30 秒…
44
+
45
+ - type: textarea
46
+ id: ui
47
+ attributes:
48
+ label: UI 變更
49
+ placeholder: 新增/調整元件、固定高度、文案、提示訊息…
50
+
51
+ - type: checkboxes
52
+ id: checklist
53
+ attributes:
54
+ label: 檢查清單
55
+ options:
56
+ - label: 我已參考並對齊 spec.md 的目標/不變條件/契約。
57
+ required: true
58
+ - label: 我已考量邊界情境與降級策略,不會阻斷整體流程。
59
+ required: true
60
+ - label: 變更涉及公共行為時,我會同步更新 spec.md。
61
+ required: true
.github/copilot-instructions.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub Copilot 指南(專案層級)
2
+
3
+ 請在本倉庫撰寫或修改程式碼時,務必遵循 `spec.md` 的契約與不變條件。以下是重點守則與常見情境處理方式:
4
+
5
+ 關鍵文件
6
+ - 必讀:`/spec.md`(專案規格與設計)
7
+ - 主要程式:`app.py`(Gradio GUI 與推論主流程)
8
+ - 參考:`ttsam_realtime.py`(即時流程樣板,不是 GUI 主流程)
9
+ - 任務追蹤:`/task.md`(跨對話追蹤的任務拆解與驗收)
10
+ - 變更摘要:`/change-log.md`(完成後的高層摘要,非逐 Task 對應)
11
+
12
+ 核心守則(務必遵守)
13
+ - 取樣率固定 100 Hz;模型輸入固定 30 秒(3000 samples)。不足 30 秒時尾段 0 填充。
14
+ - 輸入測站最多 25 站;少於 25 站允許,需在 UI 顯示警告,且仍可推論。
15
+ - N/E 分量缺失時一律以 Z 分量代替,並統計缺分站數於輸出摘要。
16
+ - 目標測站需分批推論,每批最多 25 站,最後合併全部結果。
17
+ - Folium 地圖高度固定 800px;實際震度圖缺失時以空白占位並記錄警告。
18
+ - Vs30 來自 SeisBlue/TaiwanVs30;查詢/下載失敗時改用使用者預設值(預設 600 m/s),且記錄 log。
19
+ - `station/site_info.csv` 與 `station/eew_target.csv` 欄位要求不可違反:
20
+ - site_info.csv: Station, Latitude, Longitude, Elevation
21
+ - eew_target.csv: station, latitude, longitude, elevation
22
+
23
+ 程式碼風格與作法
24
+ - 加入或變更功能時,請以註解說明與 spec 的對齊;無需使用編號標籤。
25
+ - 優先保持向後相容,不破壞現有行為;若需變更公共行為,先更新 `spec.md`。
26
+ - 對所有可能失敗點提供降級方案與 log;不要讓單站/單檔錯誤導致整體中斷。
27
+ - 避免將模型結構分散於多處;若需重構,提供明確入口(工廠或封裝函式)。
28
+
29
+ 常見任務提示
30
+ - 新增事件:
31
+ - 將 .mseed 放入 `waveform/`,更新 `app.py` 的 `EARTHQUAKE_EVENTS`。
32
+ - 若有實際震度圖,放入 `intensity_map/`,檔名 `YYYYMMDD.png`。
33
+ - 新增目標測站:
34
+ - 更新 `station/eew_target.csv`,勿修改模型與核心流程。
35
+ - 新增輸入測站:
36
+ - 更新 `station/site_info.csv`,去除重複站名列。
37
+
38
+ 提交前檢查(最低需求)
39
+ - 是否符合 `spec.md` 的輸入/輸出 shape 與不變條件?
40
+ - 少於 25 站、缺分量、Vs30 下載失敗等情境是否有測試或至少手動冒煙?
41
+ - Log 是否清楚易懂,足以追蹤降級與邊界情境?
42
+
43
+ 任務工作流:task.md(跨對話沿用)
44
+ - 在規劃或討論變更時,請於倉庫根目錄維護 `task.md`(提供模板)。
45
+ - 每個任務請填寫:背景與目標、不變條件對齊、任務拆解(可勾選)、測試與驗收、降級策略、進度紀錄。
46
+ - 子任務應小而可驗收(理想 1 小時內完成),並附明確驗收標準與冒煙測試方式。
47
+ - 若變更公共行為或 I/O shape,先更新 `spec.md`,同時在 `task.md` 標註風險與回滾策略。
48
+ - 建議以 `feature/T-<YYYYMMDD>-<shortname>` 建分支,PR 標題以 `T-<YYYYMMDD>-<shortname>` 起首;在提交訊息引用任務 ID。
49
+ - 本工作流僅為協助追蹤,不取代 `spec.md` 的契約約束。
50
+
51
+ 完成後的變更摘要與 task 重置
52
+ - 當某個任務或一批變更完成時,請在 `change-log.md` 新增一個條目,摘要說明本次改變了什麼(可跨多個 Task 合併記錄,非一對一)。
53
+ - 條目建議包含:Highlights、Spec/契約影響、行為或 I/O 變更、資料檔影響、降級與 Logging、測試與驗收、風險與回滾、連結(PR/Commits/Task IDs)。
54
+ - 條目完成後,可刪除或重置根目錄的 `task.md`;下次任務開始時請以 `docs/task-template.md` 重新建立。
.github/pull_request_template.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## 內容簡述
2
+ - 此 PR 做了什麼?(簡述變更)
3
+
4
+ ## 規格對齊(spec compliance)
5
+ - 相關規格:
6
+ - [ ] 介面/流程契約符合 `spec.md`
7
+ - [ ] 核心不變條件(取樣率 100 Hz、30 秒窗口、<=25 站、缺分量處理、分批推論、地圖高度 800px、Vs30 降級)
8
+ - [ ] 欄位要求(site_info.csv / eew_target.csv)
9
+
10
+ ## 邊界/降級驗證
11
+ - [ ] 少於 25 站可用時,UI 有明確警告且仍可推論
12
+ - [ ] N/E 缺分以 Z 代替並統計
13
+ - [ ] Vs30 載入失敗使用預設值並記錄 log
14
+ - [ ] duration < 30 秒零填充
15
+
16
+ ## 測試與驗證
17
+ - 測試方式或冒煙步驟(貼出輸出截圖/關鍵 log):
18
+
19
+ ## 其他
20
+ - 是否需要在 `spec.md` 更新條目?若是,請同步修改。
README.md CHANGED
@@ -11,3 +11,91 @@ license: gpl-3.0
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
+
15
+ ## 專案簡介
16
+ TTSAM(Taiwan Transformer-based Shake Alert Model)是一個以 Transformer 為核心的地震預警/震度推估原型,提供互動式 GUI 用於載入歷史事件、查看波形、並在地圖上比對「預測震度」與「實際震度」。
17
+
18
+ ## 主要功能
19
+ - 互動式 GUI(`app.py`):
20
+ - 選擇歷史事件、時間窗、震央座標並載入波形。
21
+ - 顯示輸入測站分布與波形(按震央距離排序)。
22
+ - 執行模型推論並在 Folium 地圖上顯示預測震度(高度固定 800px)。
23
+ - 若有實際震度圖,支援與預測對照;若缺失則以空白占位並提示。
24
+ - 穩健的資料處理:
25
+ - 取樣率固定 100 Hz;模型輸入固定 30 秒(不足補 0)。
26
+ - 輸入測站最多 25 站;不足仍可推論並在 UI 顯示警告。
27
+ - N/E 分量缺失時以 Z 替代,並統計缺分站數於摘要。
28
+ - 目標點批次推論:
29
+ - 目標測站每批最多 25 點,最後合併結果。
30
+ - 場址參數與降級:
31
+ - Vs30 以 `SeisBlue/TaiwanVs30` 下載為主;查詢/下載失敗時使用預設值 600 m/s 並記錄 log。
32
+ - 易於擴充:
33
+ - 不需改模型與核心流程即可新增事件與目標測站(更新資料檔即可)。
34
+
35
+ ## 快速開始
36
+ 需求
37
+ - Python 3.10–3.11(建議)
38
+ - 主要套件見 `requirements.txt`
39
+
40
+ 安裝與執行
41
+ - 安裝相依套件
42
+ - `pip install -r requirements.txt`
43
+ - 執行 GUI
44
+ - `python app.py`
45
+ - 或使用腳本:`./run_local.sh`
46
+
47
+ 資料與資源
48
+ - 事件波形:`waveform/*.mseed`
49
+ - 實際震度圖(選用):`intensity_map/YYYYMMDD.png`
50
+ - 站台資料:`station/site_info.csv`, `station/eew_target.csv`
51
+
52
+ ## 常見任務
53
+ - 新增事件:
54
+ - 將 `.mseed` 放入 `waveform/`,並更新 `app.py` 的 `EARTHQUAKE_EVENTS`。
55
+ - 若有實際震度圖,放入 `intensity_map/`,檔名 `YYYYMMDD.png`。
56
+ - 新增目標測站:
57
+ - 於 `station/eew_target.csv` 增補列,欄位:`station, latitude, longitude, elevation`。
58
+ - 新增輸入測站:
59
+ - 於 `station/site_info.csv` 增補列,欄位:`Station, Latitude, Longitude, Elevation`;去除重複站名列。
60
+
61
+ ## 規格不變條件(摘要)
62
+ - 取樣率:100 Hz;輸入長度:30 秒(3000 點,不足補 0)。
63
+ - 輸入測站:最多 25;不足允許,需在 UI 顯示警告。
64
+ - 缺分量:N/E 缺失以 Z 替代並統計。
65
+ - 目標批次:每批最多 25,合併全部結果。
66
+ - 地圖高度:Folium 地圖固定 800px;實際震度圖缺失以占位。
67
+ - Vs30:優先使用 `SeisBlue/TaiwanVs30`;失敗則使用預設 600 m/s 並記錄 log。
68
+
69
+ 完整細節請參考 `spec.md`。
70
+
71
+ ## 專案結構
72
+ - `app.py`:Gradio GUI 與推論主流程
73
+ - `ttsam_realtime.py`:即時流程樣板(非 GUI 主流程)
74
+ - `station/site_info.csv`:輸入測站表
75
+ - `station/eew_target.csv`:目標測站表
76
+ - `waveform/`:事件波形(.mseed)
77
+ - `intensity_map/`:實際震度圖(可選)
78
+ - `spec.md`:規格與設計說明(契約)
79
+ - `.github/copilot-instructions.md`:生成程式碼指南
80
+ - `task.md`:目前進行中的任務拆解(完成後可重置)
81
+ - `change-log.md`:每次變更的高層摘要
82
+ - `docs/task-template.md`:任務模板(重建 `task.md` 時使用)
83
+
84
+ ## 疑難排解(Troubleshooting)
85
+ - Vs30 下載失敗或查無資料
86
+ - 行為:使用預設值 600 m/s;log 會有 WARNING 訊息。
87
+ - 檢查網路或稍後再試;必要時在 UI/設定中調整預設值。
88
+ - 實際震度圖缺失
89
+ - 行為:左側顯示空白占位與提示;不影響預測地圖。
90
+ - 少於 25 個輸入測站
91
+ - 行為:UI 顯示警告,仍可推論。
92
+ - 缺少 N/E 分量
93
+ - 行為:以 Z 分量代替並在摘要統計。
94
+
95
+ ## 授權
96
+ - License:GPL-3.0
97
+
98
+ ## 進一步閱讀
99
+ - `spec.md`(契約與不變條件)
100
+ - `.github/copilot-instructions.md`(開發與貢獻指南)
101
+ - `change-log.md`(歷次變更摘要)
app.py CHANGED
@@ -55,20 +55,20 @@ 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"
@@ -76,19 +76,19 @@ 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 = {
@@ -441,7 +441,7 @@ 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
 
@@ -453,17 +453,17 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
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]
@@ -479,7 +479,7 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
479
  "elevation": elev
480
  }
481
  except Exception as e:
482
- logger.warning(f"[T012] 測站 {station_code} 資訊查詢失敗: {e}")
483
  continue
484
 
485
  # 轉換為列表並按距離排序,選擇最近的 n 個
@@ -487,10 +487,10 @@ def select_nearest_stations(st, epicenter_lat, epicenter_lon, n_stations=25):
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
 
@@ -515,30 +515,30 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
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"]
@@ -562,23 +562,23 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
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
 
@@ -587,12 +587,11 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
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)
@@ -619,7 +618,7 @@ def extract_waveforms_from_stream(st, selected_stations, start_time, duration, v
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
 
@@ -725,13 +724,13 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
725
  import folium
726
  from folium import plugins
727
 
728
- # [T006] 創建地圖,設定固定高度 800(寬度 100%),與實際震度圖區塊一致
729
  m = folium.Map(
730
  location=[23.5, 121],
731
  zoom_start=7,
732
  tiles='OpenStreetMap',
733
  width='100%',
734
- height='800px' # [T006] 固定高度 800px,不再依實際震度圖尺寸動態調整
735
  )
736
 
737
  # 如果有震央位置,標記震央
@@ -754,7 +753,6 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
754
  color = get_intensity_color(intensity)
755
  pga = pga_list[i]
756
 
757
- # 創建 HTML popup 內容
758
  popup_html = f"""
759
  <div style="font-family: Arial; min-width: 150px;">
760
  <h4 style="margin: 0 0 10px 0;">{target_name}</h4>
@@ -766,7 +764,6 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
766
  </div>
767
  """
768
 
769
- # 創建圓形標記
770
  folium.CircleMarker(
771
  location=[lat, lon],
772
  radius=12,
@@ -778,7 +775,6 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
778
  weight=2
779
  ).add_to(m)
780
 
781
- # 在圓圈中心添加震度文字
782
  folium.Marker(
783
  [lat, lon],
784
  icon=folium.DivIcon(html=f'''
@@ -792,7 +788,7 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
792
  ''')
793
  ).add_to(m)
794
 
795
- # 添加圖例
796
  legend_html = '''
797
  <div style="
798
  position: fixed;
@@ -827,7 +823,6 @@ def create_intensity_map(pga_list, target_names, epicenter_lat=None, epicenter_l
827
 
828
  m.get_root().html.add_child(folium.Element(legend_html))
829
 
830
- # 添加全屏按鈕
831
  plugins.Fullscreen().add_to(m)
832
 
833
  return m
@@ -837,16 +832,13 @@ def load_observed_intensity_image(event_name):
837
  """
838
  從 intensity_map 資料夾載入對應的實際觀測震度圖
839
 
840
- [T011] 實際震度圖不存在時:顯示提示並用預設高度 800 呈現空白占位
841
  """
842
  import os
843
 
844
- # 根據事件名稱找對應的圖片
845
- # 假設圖片命名格式為:20240403.png 或類似
846
  event_file = EARTHQUAKE_EVENTS[event_name]
847
  event_date = os.path.basename(event_file).replace('.mseed', '')
848
 
849
- # 嘗試不同的圖片格式
850
  intensity_map_dir = "intensity_map"
851
  possible_extensions = ['.png', '.jpg', '.jpeg', '.gif']
852
 
@@ -856,8 +848,7 @@ def load_observed_intensity_image(event_name):
856
  logger.info(f"載入實際觀測震度圖: {image_path}")
857
  return image_path
858
 
859
- # [T011] 圖片不存在時記錄警告(UI 會用預設高度 800 顯示空白占位)
860
- logger.warning(f"[T011] 找不到實際震度圖: {event_date}(將顯示空白占位)")
861
  return None
862
 
863
 
@@ -867,7 +858,6 @@ def on_event_select(event_name):
867
  if observed_intensity_path:
868
  return observed_intensity_path
869
  else:
870
- # 如果找不到圖片,返回 None(Gradio 會顯示空白)
871
  return None
872
 
873
 
@@ -876,26 +866,23 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
876
  import folium
877
  from folium import plugins
878
 
879
- # [T007] 創建地圖,設定固定高度 800(寬度 100%),與實際震度圖區塊一致
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(用於快速查詢)
889
  selected_station_codes = {s["station"] for s in selected_stations}
890
 
891
- # 1. 先繪製所有測站(灰色小點)
892
  logger.info(f"繪製所有測站 ({len(site_info)} 個)...")
893
  for idx, row in site_info.iterrows():
894
  station_code = row["Station"]
895
  lat = row["Latitude"]
896
  lon = row["Longitude"]
897
 
898
- # 跳過被選中的測站(稍後用不同樣式繪製)
899
  if station_code in selected_station_codes:
900
  continue
901
 
@@ -910,7 +897,6 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
910
  weight=1
911
  ).add_to(m)
912
 
913
- # 2. 標記震央(紅色星星)
914
  folium.Marker(
915
  [epicenter_lat, epicenter_lon],
916
  popup=f'<b>震央</b><br>({epicenter_lat:.3f}, {epicenter_lon:.3f})',
@@ -919,14 +905,12 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
919
  zIndexOffset=1000
920
  ).add_to(m)
921
 
922
- # 3. 標記被選中的 25 個測站(彩色大點)
923
  for i, station_data in enumerate(selected_stations):
924
  station_code = station_data["station"]
925
  lat = station_data["latitude"]
926
  lon = station_data["longitude"]
927
  distance = station_data["distance"]
928
 
929
- # 創建 popup 內容
930
  popup_html = f"""
931
  <div style="font-family: Arial; min-width: 150px;">
932
  <h4 style="margin: 0 0 10px 0; color: #d63031;">{station_code}</h4>
@@ -939,7 +923,6 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
939
  </div>
940
  """
941
 
942
- # 根據距離設定顏色
943
  if i < 5:
944
  color = 'green'
945
  elif i < 15:
@@ -959,7 +942,6 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
959
  zIndexOffset=500
960
  ).add_to(m)
961
 
962
- # 4. 添加圖例
963
  total_stations = len(site_info)
964
  legend_html = f'''
965
  <div style="
@@ -988,7 +970,6 @@ def create_input_station_map(selected_stations, epicenter_lat, epicenter_lon):
988
 
989
  m.get_root().html.add_child(folium.Element(legend_html))
990
 
991
- # 5. 添加全屏按鈕
992
  plugins.Fullscreen().add_to(m)
993
 
994
  return m
@@ -1019,7 +1000,7 @@ def load_and_display_waveform(event_name, start_time, duration, epicenter_lon, e
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"
@@ -1043,10 +1024,10 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
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 檔案
@@ -1079,16 +1060,16 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
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
@@ -1114,7 +1095,7 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
1114
  for i in range(len(target_list)):
1115
  target_padded[i] = target_list[i]
1116
 
1117
- # 6. 組合成 tensor
1118
  tensor_data = {
1119
  "waveform": torch.tensor(waveform_padded).unsqueeze(0).double(),
1120
  "station": torch.tensor(station_info_padded).unsqueeze(0).double(),
@@ -1130,18 +1111,18 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
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. 載入實際觀測震度圖(filepath;左側以 800 高顯示)
1142
  observed_intensity_path = load_observed_intensity_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"
@@ -1150,11 +1131,10 @@ def predict_intensity(event_name, start_time, duration, epicenter_lon, epicenter
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] 回傳:實際觀測震度圖(filepath;左側 800 高)、預測地圖(HTML;右側 800 高)、統計資訊
1158
  return observed_intensity_path, map_html, stats
1159
 
1160
  except Exception as e:
@@ -1236,26 +1216,23 @@ with gr.Blocks(title="TTSAM 震度預測系統") as demo:
1236
  label="實際觀測震度",
1237
  type="filepath",
1238
  height=800,
1239
- value=load_observed_intensity_image(list(EARTHQUAKE_EVENTS.keys())[0]) # 初始載入第一個事件的實際震度圖
1240
  )
1241
 
1242
 
1243
  # 綁定事件
1244
- # 第零步:選擇事件時立即載入實際觀測震度圖
1245
  event_dropdown.change(
1246
  fn=on_event_select,
1247
  inputs=[event_dropdown],
1248
  outputs=[observed_intensity_image]
1249
  )
1250
 
1251
- # 第一步:載入波形
1252
  load_waveform_btn.click(
1253
  fn=load_and_display_waveform,
1254
  inputs=[event_dropdown, start_slider, duration_slider, epicenter_lon_input, epicenter_lat_input],
1255
  outputs=[input_station_map, waveform_plot, info_output, predict_btn]
1256
  )
1257
 
1258
- # 第二步:執行預測
1259
  predict_btn.click(
1260
  fn=predict_intensity,
1261
  inputs=[event_dropdown, start_slider, duration_slider, epicenter_lon_input, epicenter_lat_input],
 
55
  logger.info(f"載入 {site_info_file}...")
56
  site_info = pd.read_csv(site_info_file)
57
 
58
+ # 驗證 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"{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"{site_info_file} 找不到")
70
  except Exception as e:
71
+ logger.error(f"{site_info_file} 載入失敗: {e}")
72
 
73
  # 載入目標測站
74
  target_file = "station/eew_target.csv"
 
76
  logger.info(f"載入 {target_file}...")
77
  target_df = pd.read_csv(target_file)
78
 
79
+ # 驗證 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"{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"{target_file} 找不到")
90
  except Exception as e:
91
+ logger.error(f"{target_file} 載入失敗: {e}")
92
 
93
  # 預設地震事件
94
  EARTHQUAKE_EVENTS = {
 
441
  """
442
  從 site_info(1000+ 個輸入測站)中選擇距離震央最近的 n 個測站
443
 
444
+ 少於 25 站可用:UI 明示實際用站數並允許繼續
445
  """
446
  station_distances = {} # 改用字典避免重複
447
 
 
453
  if station_code in station_distances:
454
  continue
455
 
456
+ # 從 site_info 中查詢測站位置(處理缺漏欄位)
457
  try:
458
  station_data = site_info[site_info["Station"] == station_code]
459
  if len(station_data) == 0:
460
  continue
461
 
462
+ # 驗證必要欄位存在
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"測站 {station_code} 缺少必要欄位: {missing_fields},跳過")
467
  continue
468
 
469
  lat = station_data["Latitude"].values[0]
 
479
  "elevation": elev
480
  }
481
  except Exception as e:
482
+ logger.warning(f"測站 {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
+ # 記錄實際可用的測站數(少於 25 站也允許繼續)
491
  actual_count = len(selected_stations)
492
  if actual_count < n_stations:
493
+ logger.warning(f"僅找到 {actual_count} 個可用測站(目標 {n_stations} 個),將繼續處理")
494
  else:
495
  logger.info(f"從 {len(station_list)} 個輸入測站中選擇了最近的 {actual_count} 個")
496
 
 
515
  - missing_components_count: 缺少分量的測站數量
516
 
517
  Note:
518
+ - 內部計算 end_time = start_time + duration
519
+ - 若 duration < 30 秒,尾段以 0 遮罩補齊至 30 秒(3000 samples @ 100 Hz)
520
+ - 缺少 N/E 分量時以 Z 分量代替,並在狀態訊息中記錄缺分量站數
521
  """
522
  waveforms = []
523
  station_info_list = []
524
  valid_stations = []
525
+ missing_components_count = 0
526
 
527
  sampling_rate = 100 # 100 Hz
528
  min_duration = 30.0 # 最小時間長度 30 秒
529
  target_length = 3000 # 30 秒 @ 100 Hz = 3000 samples
530
 
531
+ # 內部計算 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
+ # 檢查是否需要零填充:長度不足 30 秒時尾段以 0 遮罩補齊
539
  needs_padding = duration < min_duration
540
  if needs_padding:
541
+ logger.info(f"時間長度 {duration} 秒 < 30 秒,將以 0 遮罩補齊至 {min_duration} 秒")
542
 
543
  for station_data in selected_stations:
544
  station_code = station_data["station"]
 
562
  else:
563
  continue
564
 
565
+ # 檢查 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"測站 {station_code} 缺少 N 分量,以 Z 分量代替")
572
 
573
+ # 檢查 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"測站 {station_code} 缺少 E 分量,以 Z 分量代替")
580
 
581
+ # 記錄缺少分量的測站(將在狀態訊息中顯示)
582
  if station_missing_components:
583
  missing_components_count += 1
584
 
 
587
  n_data = signal_processing(n_data)
588
  e_data = signal_processing(e_data)
589
 
590
+ # 創建全零陣列 (3000, 3) - 確保至少 30 秒長度
591
  # 不足 30 秒時,尾段以 0 遮罩補齊
592
  waveform_3c = np.zeros((target_length, 3))
593
 
594
+ # 填入實際資料(處理長度不足或過長的情況)
 
595
  z_len = min(len(z_data), target_length)
596
  n_len = min(len(n_data), target_length)
597
  e_len = min(len(e_data), target_length)
 
618
 
619
  logger.info(f"成功提取 {len(waveforms)} 個測站的波形")
620
  if missing_components_count > 0:
621
+ logger.info(f"其中 {missing_components_count} 個測站缺少 N 或 E 分量(已以 Z 分量代替)")
622
 
623
  return waveforms, station_info_list, valid_stations, missing_components_count
624
 
 
724
  import folium
725
  from folium import plugins
726
 
727
+ # 創建地圖,固定高度 800(寬度 100%)
728
  m = folium.Map(
729
  location=[23.5, 121],
730
  zoom_start=7,
731
  tiles='OpenStreetMap',
732
  width='100%',
733
+ height='800px'
734
  )
735
 
736
  # 如果有震央位置,標記震央
 
753
  color = get_intensity_color(intensity)
754
  pga = pga_list[i]
755
 
 
756
  popup_html = f"""
757
  <div style="font-family: Arial; min-width: 150px;">
758
  <h4 style="margin: 0 0 10px 0;">{target_name}</h4>
 
764
  </div>
765
  """
766
 
 
767
  folium.CircleMarker(
768
  location=[lat, lon],
769
  radius=12,
 
775
  weight=2
776
  ).add_to(m)
777
 
 
778
  folium.Marker(
779
  [lat, lon],
780
  icon=folium.DivIcon(html=f'''
 
788
  ''')
789
  ).add_to(m)
790
 
791
+ # 圖例
792
  legend_html = '''
793
  <div style="
794
  position: fixed;
 
823
 
824
  m.get_root().html.add_child(folium.Element(legend_html))
825
 
 
826
  plugins.Fullscreen().add_to(m)
827
 
828
  return m
 
832
  """
833
  從 intensity_map 資料夾載入對應的實際觀測震度圖
834
 
835
+ 實際震度圖不存在時:顯示提示並用預設高度 800 呈現空白占位
836
  """
837
  import os
838
 
 
 
839
  event_file = EARTHQUAKE_EVENTS[event_name]
840
  event_date = os.path.basename(event_file).replace('.mseed', '')
841
 
 
842
  intensity_map_dir = "intensity_map"
843
  possible_extensions = ['.png', '.jpg', '.jpeg', '.gif']
844
 
 
848
  logger.info(f"載入實際觀測震度圖: {image_path}")
849
  return image_path
850
 
851
+ logger.warning(f"找不到實際震度圖: {event_date}(將顯示空白占位)")
 
852
  return None
853
 
854
 
 
858
  if observed_intensity_path:
859
  return observed_intensity_path
860
  else:
 
861
  return None
862
 
863
 
 
866
  import folium
867
  from folium import plugins
868
 
869
+ # 創建地圖,固定高度 800(寬度 100%)
870
  m = folium.Map(
871
  location=[epicenter_lat, epicenter_lon],
872
  zoom_start=8,
873
  tiles='OpenStreetMap',
874
  width='100%',
875
+ height='800px'
876
  )
877
 
 
878
  selected_station_codes = {s["station"] for s in selected_stations}
879
 
 
880
  logger.info(f"繪製所有測站 ({len(site_info)} 個)...")
881
  for idx, row in site_info.iterrows():
882
  station_code = row["Station"]
883
  lat = row["Latitude"]
884
  lon = row["Longitude"]
885
 
 
886
  if station_code in selected_station_codes:
887
  continue
888
 
 
897
  weight=1
898
  ).add_to(m)
899
 
 
900
  folium.Marker(
901
  [epicenter_lat, epicenter_lon],
902
  popup=f'<b>震央</b><br>({epicenter_lat:.3f}, {epicenter_lon:.3f})',
 
905
  zIndexOffset=1000
906
  ).add_to(m)
907
 
 
908
  for i, station_data in enumerate(selected_stations):
909
  station_code = station_data["station"]
910
  lat = station_data["latitude"]
911
  lon = station_data["longitude"]
912
  distance = station_data["distance"]
913
 
 
914
  popup_html = f"""
915
  <div style="font-family: Arial; min-width: 150px;">
916
  <h4 style="margin: 0 0 10px 0; color: #d63031;">{station_code}</h4>
 
923
  </div>
924
  """
925
 
 
926
  if i < 5:
927
  color = 'green'
928
  elif i < 15:
 
942
  zIndexOffset=500
943
  ).add_to(m)
944
 
 
945
  total_stations = len(site_info)
946
  legend_html = f'''
947
  <div style="
 
970
 
971
  m.get_root().html.add_child(folium.Element(legend_html))
972
 
 
973
  plugins.Fullscreen().add_to(m)
974
 
975
  return m
 
1000
  station_map = create_input_station_map(selected_stations, epicenter_lat, epicenter_lon)
1001
  station_map_html = station_map._repr_html_()
1002
 
1003
+ # 明示實際用站數(少於 25 站時顯示警告)
1004
  info_text = f"✅ 已載入波形資料\n"
1005
  info_text += f"開始時間: {start_time:.1f} 秒\n"
1006
  info_text += f"時間長度: {duration:.1f} 秒 ({start_time:.1f} - {end_time:.1f})\n"
 
1024
  """
1025
  執行震度預測
1026
 
1027
+ 介面改以 start+duration,使用固定高度 800 的地圖輸出
1028
  """
1029
  try:
1030
+ # 計算結束時間(內部處理)
1031
  end_time = start_time + duration
1032
 
1033
  # 1. 載入完整的 mseed 檔案
 
1060
  waveform_padded[i] = waveforms[i]
1061
  station_info_padded[i] = station_info_list[i]
1062
 
1063
+ # 準備所有目標測站資訊(分批處理)
1064
  all_pga_list = []
1065
  all_target_names = []
1066
 
1067
+ # 計算需要分幾批(每批 25 個測站)
1068
  batch_size = 25
1069
  total_targets = len(target_dict)
1070
  num_batches = (total_targets + batch_size - 1) // batch_size
1071
 
1072
+ logger.info(f"開始分批預測 {total_targets} 個目標測站(共 {num_batches} 批)...")
1073
 
1074
  for batch_idx in range(num_batches):
1075
  start_idx = batch_idx * batch_size
 
1095
  for i in range(len(target_list)):
1096
  target_padded[i] = target_list[i]
1097
 
1098
+ # 6. 組合成 torch tensor
1099
  tensor_data = {
1100
  "waveform": torch.tensor(waveform_padded).unsqueeze(0).double(),
1101
  "station": torch.tensor(station_info_padded).unsqueeze(0).double(),
 
1111
  all_pga_list.extend(batch_pga[:len(target_names)])
1112
  all_target_names.extend(target_names)
1113
 
1114
+ logger.info(f"完成所有 {len(all_target_names)} 個測站的預測!")
1115
  pga_list = all_pga_list
1116
  target_names = all_target_names
1117
 
1118
+ # 繪製互動式地圖(固定高度 800)
1119
  intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
1120
  map_html = intensity_map._repr_html_()
1121
 
1122
+ # 載入實際觀測震度圖(filepath;左側以 800 高顯示)
1123
  observed_intensity_path = load_observed_intensity_image(event_name)
1124
 
1125
+ # 統計資訊(包含目標點數)
1126
  max_intensity = max([calculate_intensity(pga, label=True) for pga in pga_list])
1127
  stats = f"✅ 預測完成!\n"
1128
  stats += f"開始時間: {start_time:.1f} 秒\n"
 
1131
  stats += f"使用測站數: {len(waveforms)} / 25\n"
1132
  if missing_components_count > 0:
1133
  stats += f"⚠️ 缺少 N/E 分量測站數: {missing_components_count} (已以 Z 分量代替)\n"
1134
+ stats += f"預測目標點數: {len(all_target_names)}\n"
1135
  stats += f"預測最大震度: {max_intensity}"
1136
 
1137
  logger.info("預測完成!")
 
1138
  return observed_intensity_path, map_html, stats
1139
 
1140
  except Exception as e:
 
1216
  label="實際觀測震度",
1217
  type="filepath",
1218
  height=800,
1219
+ value=load_observed_intensity_image(list(EARTHQUAKE_EVENTS.keys())[0])
1220
  )
1221
 
1222
 
1223
  # 綁定事件
 
1224
  event_dropdown.change(
1225
  fn=on_event_select,
1226
  inputs=[event_dropdown],
1227
  outputs=[observed_intensity_image]
1228
  )
1229
 
 
1230
  load_waveform_btn.click(
1231
  fn=load_and_display_waveform,
1232
  inputs=[event_dropdown, start_slider, duration_slider, epicenter_lon_input, epicenter_lat_input],
1233
  outputs=[input_station_map, waveform_plot, info_output, predict_btn]
1234
  )
1235
 
 
1236
  predict_btn.click(
1237
  fn=predict_intensity,
1238
  inputs=[event_dropdown, start_slider, duration_slider, epicenter_lon_input, epicenter_lat_input],
change-log.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Change Log
2
+
3
+ 用途
4
+ - 用於在任務完成後,將改動簡化整理為高層摘要,方便回顧與版本對齊。
5
+ - 無需與 `task.md` 一對一對應;可依里程碑/批次/日期合併紀錄。
6
+
7
+ 使用方式
8
+ - 每次完成一批有意義的變更(合併 PR、完成里程碑、或結束一輪對話)時,新增一個條目。
9
+ - 條目內容建議包含下列要點;若無則可省略,但公共行為與 I/O shape 變更必寫:
10
+ - Highlights(做了什麼、為何做)
11
+ - Spec/契約影響(是否更新 `spec.md`、不變條件有無變動)
12
+ - 行為或 I/O 變更(UI、張量 shape、欄位變化與相容性)
13
+ - 資料/站台檔影響(`station/*.csv` 欄位或資料需求變更)
14
+ - 降級與 Logging(新增/調整的降級策略、關鍵 log 片語)
15
+ - 測試與驗收(新增/更新的測試、手動冒煙結果要點)
16
+ - 風險與回滾(若適用)
17
+ - 連結(PR、Commits、相關 Task IDs 可選)
18
+
19
+ ---
20
+
21
+ ## 2025-10-25 — 文件與工作流強化:task.md 與 change-log.md
22
+ - Highlights
23
+ - 新增 `task.md` 工作流與 `docs/task-template.md`,支援跨對話任務拆解與驗收。
24
+ - 新增 `change-log.md`,集中紀錄高層摘要,完成批次後可重置 `task.md`。
25
+ - 擴充 `.github/copilot-instructions.md` 與 `README.md`,說明任務→摘要→重置之流程。
26
+ - Spec/契約影響
27
+ - 無功能性調整;僅文件與流程。沿用既有不變條件(100 Hz/30s、<=25 站、Z 代 NE、Vs30 降級、地圖 800px)。
28
+ - 行為或 I/O 變更
29
+ - 無;僅文件與維運工作流。
30
+ - 資料/站台檔影響
31
+ - 無。
32
+ - 降級與 Logging
33
+ - 無變更;維持現行策略。
34
+ - 測試與驗收
35
+ - 手動檢查:新增/編輯的文檔檔案可正確顯示;連結指向正確位置。
36
+ - 風險與回滾
37
+ - 低;如需回滾,移除新增文檔即可。
38
+ - 連結
39
+ - Task: T-20251025-docs-workflow(規劃)
docs/task-template.md ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Task: <在此填寫任務標題>
2
+
3
+ - Task ID: T-<YYYYMMDD>-<shortname>
4
+ - Owner: <name>
5
+ - Date: <YYYY-MM-DD>
6
+ - Status: Planning | In Progress | Review | Done
7
+
8
+ ## 背景與目標
9
+ - 背景:為何需要這個變更?與 `spec.md` 的哪一段相關?
10
+ - 目標:本次要達成的使用者價值與交付物(可量化)。
11
+ - 不在範圍:明確說明此次不處理的部分,避免 scope creep。
12
+
13
+ ## 相關文件與位置
14
+ - 規格:`/spec.md`(若公共行為變更,務必同步更新)
15
+ - 主程式:`app.py`
16
+ - 即時樣板:`ttsam_realtime.py`
17
+ - 站台資料:`station/site_info.csv`, `station/eew_target.csv`
18
+ - 其他:<連結到 PR、議題、筆記、外部參考>
19
+
20
+ ## 不變條件與約束(對齊 spec 要點)
21
+ - 取樣率固定 100 Hz;模型輸入固定 30 秒(3000 samples)。不足 30 秒時尾段 0 填充。
22
+ - 輸入測站最多 25 站;少於 25 站允許,需在 UI 顯示警告,且仍可推論。
23
+ - N/E 分量缺失時一律以 Z 分量代替,並統計缺分站數於輸出摘要。
24
+ - 目標測站需分批推論,每批最多 25 站,最後合併全部結果。
25
+ - Folium 地圖高度固定 800px;實際震度圖缺失時以空白占位並記錄警告。
26
+ - Vs30 來自 SeisBlue/TaiwanVs30;查詢/下載失敗時改用使用者預設值(預設 600 m/s),且記錄 log。
27
+ - `station/site_info.csv` 欄位:Station, Latitude, Longitude, Elevation。
28
+ - `station/eew_target.csv` 欄位:station, latitude, longitude, elevation。
29
+
30
+ > 若本任務可能影響以上不變條件,請明列風險與必要的 `spec.md` 更新計畫。
31
+
32
+ ## 任務拆解(可勾選,可跨對話沿用)
33
+ - [ ] 子任務 1:<描述>(變更檔案:`...`)
34
+ - 驗收標準:<明確可驗證的條件>
35
+ - 測試/冒煙:<如何驗證,輸入/輸出示例>
36
+ - [ ] 子任務 2:<描述>(變更檔案:`...`)
37
+ - 驗收標準:<...>
38
+ - 測試/冒煙:<...>
39
+ - [ ] 子任務 3:<描述>(變更檔案:`...`)
40
+ - 驗收標準:<...>
41
+ - 測試/冒煙:<...>
42
+
43
+ > 建議每個子任務都能在 1 小時內完成並可獨立驗收;大型變更請再細分。
44
+
45
+ ## 介面與資料形狀變更評估
46
+ - UI 影響:是否需要新增/修改警告訊息或欄位?
47
+ - I/O shape:輸入/輸出張量或資料欄位是否改變?
48
+ - 向後相容:如何保持既有使用者不受影響?若無法,請先更新 `spec.md` 並標註破壞性變更。
49
+
50
+ ## 降級與記錄策略
51
+ - 可能失敗點:<列出>(例如:Vs30 查詢失敗、缺分量、測站不足等)
52
+ - 降級方案:<對應每一種失敗點的替代行為>
53
+ - Logging:<關鍵訊息,包含警告/錯誤的字串格式,便於追蹤>
54
+
55
+ ## 測試與驗收
56
+ - 單元/整合測試:<若有測試框架,列出測試案例>
57
+ - 手動冒煙:
58
+ - 啟動方式:`./run_local.sh` 或 `python app.py`(視專案而定)
59
+ - 驗收步驟:<步驟 1, 2, 3>
60
+ - 預期結果:<清楚描述>
61
+ - 品質檢查(最低):
62
+ - [ ] Build/型別/格式檢查通過
63
+ - [ ] 關鍵邊界情境可正確降級並記錄 log
64
+ - [ ] 與 `spec.md` 之契約一致
65
+
66
+ ## 風險、回滾與後續
67
+ - 風險:<列出主要風險與緩解方式>
68
+ - 回滾:<若出問題如何快速回復>
69
+ - 後續工作:<可選,列出延伸 task>
70
+
71
+ ## 進度紀錄(跨對話追蹤)
72
+ - <YYYY-MM-DD HH:MM>:<進度更新,關鍵決策/PR 連結/驗收結果>
73
+
74
+ ---
75
+
76
+ 附錄 A:Commit 信息模板
77
+
78
+ ```
79
+ feat(task T-<YYYYMMDD>-<shortname>): <簡述>
80
+
81
+ - <子任務/變更點 1>
82
+ - <子任務/變更點 2>
83
+
84
+ Refs: T-<YYYYMMDD>-<shortname>
85
+ ```
86
+
87
+ 附錄 B:分支/PR 命名
88
+ - 分支:`feature/T-<YYYYMMDD>-<shortname>`
89
+ - PR 標題:`T-<YYYYMMDD>-<shortname>: <簡述>`
90
+
91
+
spec.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TTSAM 規格與設計說明(spec.md)
2
+
3
+ 本文件記錄 TTSAM(Taiwan Transformer-based Shake Alert Model)的設計理念、需求規格、資料流程、核心不變條件(invariants)、常數及可擴充點,用來指導未來的需求討論與程式碼產生。撰寫新功能、修正 bug、或重構前,請先確認是否符合此文件。
4
+
5
+ — 最後更新:2025-10-25
6
+
7
+
8
+ ## 1. 專案目標與範疇
9
+ - 目標
10
+ - 從地震波形資料(MSEED 或即時串流)與測站場址參數推估全台各目標測站的 PGA 與震度分布。
11
+ - 提供互動式地圖與圖表,協助快速比對「預測結果」與「實際觀測」。
12
+ - 非目標
13
+ - 不是地震檢測與定位系統(震央位置需由使用者/上游系統提供)。
14
+ - 不負責資料蒐集(即時系統中由 Earthworm/外部來源提供)。
15
+
16
+
17
+ ## 2. 使用者故事(User Stories)與驗收標準
18
+ 1) 作為地震分析人員,我想用 GUI 選擇歷史事件、時間窗與震央位置,快速看到波形與預測震度圖。
19
+ - 驗收:
20
+ - 可於 UI 選擇事件、start、duration、震央經緯度。
21
+ - 載入後顯示:輸入測站分布、所選時間窗的波形(距離排序)、並可再按一次「執行預測」。
22
+
23
+ 2) 作為預警作業人員,我想看到「預測震度圖」與「實際震度圖」對照,並有清楚統計摘要。
24
+ - 驗收:
25
+ - 右側顯示互動式預測地圖(固定高度 800px),左側顯示實際震度圖或空白占位。
26
+ - 提供文字摘要:使用測站數、缺分量統計、目標點數、最大震度、時間窗資訊。
27
+
28
+ 3) 作為系統維運人員,我需要在測站資料或 Vs30 缺漏時系統仍可運作,並清楚告知降級模式。
29
+ - 驗收:
30
+ - 站點欄位缺漏或找不到時跳過(或降級處理),持續運行並記錄 log。
31
+ - Vs30 查詢失敗時使用使用者預設值(預設 600 m/s),記 log。
32
+
33
+ 4) 作為開發者,我想新增更多目標測站/事件,不需要改模型或核心流程。
34
+ - 驗收:
35
+ - 在 `station/eew_target.csv`、`EARTHQUAKE_EVENTS` 增補資料,即可被 UI/預測流程正確使用。
36
+
37
+
38
+ ## 3. 架構與資料流程(高層)
39
+ - Gradio 應用(`app.py`)
40
+ - 載入事件 MSEED(ObsPy) → 選 25 近站(`site_info.csv`) → 訊號處理(去平均、10 Hz 低通)→ 波形/站點打包 → 模型預測 → Folium 地圖輸出。
41
+ - 以 Hugging Face 下載臺灣 Vs30 NetCDF,失敗時降級使用使用者輸入值。
42
+ - 分批對目標站(`eew_target.csv`)做推論,每批最多 25 點。
43
+ - 模型(內建於 `app.py`)
44
+ - CNN 特徵擷取 + 位置/場址(lat, lon, elev, Vs30)Positional Embedding → Transformer Encoder → MLP → MDN → PGA 期望值。
45
+ - 即時系統(`ttsam_realtime.py`,輔助參考)
46
+ - Earthworm→Flask/SocketIO 顯示,流程與核心概念一致,但非本 GUI 的執行主體。
47
+
48
+
49
+ ## 4. 核心不變條件(Invariants)與常數
50
+ - 取樣率與窗口
51
+ - 取樣率固定為 100 Hz。
52
+ - 模型輸入固定長度 30 秒 → 3000 點;若小於 30 秒,尾段以 0 遮罩補齊。
53
+ - 測站數
54
+ - 輸入測站最多 25 個;若不足 25 仍可推論並在 UI 顯示警告。
55
+ - 目標測站以 25 點為一批分批推論,最後合併。
56
+ - 缺分量處理
57
+ - N/E 缺失時以 Z 分量代替,並統計缺分站數於輸出摘要。
58
+ - 震度定義
59
+ - 依 PGA 門檻(log10 m/s^2)映射至 0,1,2,3,4,5-,5+,6-,6+,7 十級。
60
+ - 地圖與圖表
61
+ - Folium 地圖高度固定 800px(輸入測站地圖與預測地圖一致),實際震度圖缺失時以空白占位。
62
+ - VS30 來源與降級
63
+ - 預設從 `SeisBlue/TaiwanVs30` 下載,失敗或查無資料時使用預設/使用者輸入值(預設 600 m/s),記錄 log。
64
+ - 檔案欄位要求
65
+ - `station/site_info.csv` 必備欄位:`Station, Latitude, Longitude, Elevation`。
66
+ - `station/eew_target.csv` 必備欄位:`station, latitude, longitude, elevation`。
67
+
68
+
69
+ ## 5. 介面與資料契約(Contracts)
70
+ - 模型輸入張量(單批):
71
+ - waveform: shape (1, 25, 3000, 3);若不足 25 以 0 padding。
72
+ - station: shape (1, 25, 4);欄位 [lat, lon, elev, vs30];不足 25 以 0 padding。
73
+ - target: shape (1, 25, 4);欄位 [lat, lon, elev, vs30];不足 25 以 0 padding(但僅取有效目標數的輸出)。
74
+ - 模型輸出:
75
+ - weight, sigma, mu → 經 `sum(weight*mu, axis=gaussians)` 得每目標點 PGA(log 值單位依訓練定義,現行流程以此值映射至震度)。
76
+ - 訊號處理:
77
+ - detrend(常數去平均)→ 10 Hz Butterworth 低通(4 階,SOS)→ 正規化與模型處理流程。
78
+ - 最近站選取:
79
+ - 以 site_info 座標與震央經緯度的平面距離排序,取前 25;去重複(同站不同分量)後以站為單位。
80
+
81
+
82
+ ## 6. 錯誤處理與 Log 原則
83
+ - 不因單站/單檔錯誤中止全流程,盡量降級繼續:
84
+ - 缺站欄位、找不到波形分量、Vs30 查詢失敗等,一律記錄 log 並採取替代方案。
85
+ - 使用 loguru;關鍵節點需有 INFO/WARNING/ERROR 訊息,註解請對齊本規格並說明降級策略。
86
+
87
+
88
+ ## 7. 檔案與資源
89
+ - app.py:Gradio 應用與推論主流程。
90
+ - ttsam_realtime.py:即時系統樣板(非 GUI 主流程)。
91
+ - station/site_info.csv:輸入測站表(去重複至「站」層級)。
92
+ - station/eew_target.csv:目標測站表。
93
+ - waveform/*.mseed:事件波形檔。
94
+ - intensity_map/YYYYMMDD.png:實際震度圖;缺失時由 UI 呈現空白。
95
+ - Hugging Face:SeisBlue/TaiwanVs30(NetCDF)。
96
+
97
+
98
+ ## 8. 擴充與維護
99
+ - 新增事件:
100
+ - 將 MSEED 放入 `waveform/`,並於 `EARTHQUAKE_EVENTS` 新增鍵值;若有實際震度圖,放到 `intensity_map/`,檔名對應日期。
101
+ - 新增目標測站:
102
+ - 於 `station/eew_target.csv` 追加列,欄位齊全。
103
+ - 新增輸入測站:
104
+ - 於 `station/site_info.csv` 追加列,欄位齊全;同站不同分量不必新增多列。
105
+ - 調整 UI:
106
+ - 地圖高度固定 800px;新增元件時保持資訊對稱與清晰;不可移除不變條件檢查。
107
+
108
+
109
+ ## 9. 開發環境與相依
110
+ - Python 建議版本:3.10–3.11(實測相容性佳)。
111
+ - 主要套件:gradio, obspy, numpy, matplotlib, xarray, netCDF4, scipy, pandas, loguru, huggingface_hub, folium, torch。
112
+ - GPU 非必需;若可用則自動使用 CUDA。
113
+
114
+
115
+ ## 10. 測試建議(最低門檻)
116
+ - 快速冒煙測試:
117
+ - 可成功載入預設事件;可顯示輸入測站地圖、波形圖。
118
+ - 能按「執行預測」產生預測地圖與統計摘要;實際震度圖存在與不存在皆能正常呈現。
119
+ - 邊界情境:
120
+ - 少於 25 個可用站、N/E 缺分、Vs30 載入失敗、`site_info.csv`/`eew_target.csv` 欄位缺漏、duration < 30 秒。
121
+
122
+
123
+ ## 11. 變更紀錄(Decision Log)
124
+ - 2025-10-25:建立初版規格,整理 UI、資料流程與模型契約,明確化不變條件與錯誤降級策略。
125
+
126
+
127
+ ## 12. 範例:新增規格項(模板)
128
+ - 規格標題
129
+ - 動機:...
130
+ - 行為:...
131
+ - 契約(輸入/輸出/限制):...
132
+ - 邊界情境與降級:...
133
+ - UI 顯示:...
134
+ - 驗收:...
task.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Task: <在此填寫任務標題>
2
+
3
+ - Task ID: T-<YYYYMMDD>-<shortname>
4
+ - Owner: <name>
5
+ - Date: <YYYY-MM-DD>
6
+ - Status: Planning | In Progress | Review | Done
7
+
8
+ ## 背景與目標
9
+ - 背景:為何需要這個變更?與 `spec.md` 的哪一段相關?
10
+ - 目標:本次要達成的使用者價值與交付物(可量化)。
11
+ - 不在範圍:明確說明此次不處理的部分,避免 scope creep。
12
+
13
+ ## 相關文件與位置
14
+ - 規格:`/spec.md`(若公共行為變更,務必同步更新)
15
+ - 主程式:`app.py`
16
+ - 即時樣板:`ttsam_realtime.py`
17
+ - 站台資料:`station/site_info.csv`, `station/eew_target.csv`
18
+ - 其他:<連結到 PR、議題、筆記、外部參考>
19
+
20
+ ## 不變條件與約束(對齊 spec 要點)
21
+ - 取樣率固定 100 Hz;模型輸入固定 30 秒(3000 samples)。不足 30 秒時尾段 0 填充。
22
+ - 輸入測站最多 25 站;少於 25 站允許,需在 UI 顯示警告,且仍可推論。
23
+ - N/E 分量缺失時一律以 Z 分量代替,並統計缺分站數於輸出摘要。
24
+ - 目標測站需分批推論,每批最多 25 站,最後合併全部結果。
25
+ - Folium 地圖高度固定 800px;實際震度圖缺失時以空白占位並記錄警告。
26
+ - Vs30 來自 SeisBlue/TaiwanVs30;查詢/下載失敗時改用使用者預設值(預設 600 m/s),且記錄 log。
27
+ - `station/site_info.csv` 欄位:Station, Latitude, Longitude, Elevation。
28
+ - `station/eew_target.csv` 欄位:station, latitude, longitude, elevation。
29
+
30
+ > 若本任務可能影響以上不變條件,請明列風險與必要的 `spec.md` 更新計畫。
31
+
32
+ ## 任務拆解(可勾選,可跨對話沿用)
33
+ - [ ] 子任務 1:<描述>(變更檔案:`...`)
34
+ - 驗收標準:<明確可驗證的條件>
35
+ - 測試/冒煙:<如何驗證,輸入/輸出示例>
36
+ - [ ] 子任務 2:<描述>(變更檔案:`...`)
37
+ - 驗收標準:<...>
38
+ - 測試/冒煙:<...>
39
+ - [ ] 子任務 3:<描述>(變更檔案:`...`)
40
+ - 驗收標準:<...>
41
+ - 測試/冒煙:<...>
42
+
43
+ > 建議每個子任務都能在 1 小時內完成並可獨立驗收;大型變更請再細分。
44
+
45
+ ## 介面與資料形狀變更評估
46
+ - UI 影響:是否需要新增/修改警告訊息或欄位?
47
+ - I/O shape:輸入/輸出張量或資料欄位是否改變?
48
+ - 向後相容:如何保持既有使用者不受影響?若無法,請先更新 `spec.md` 並標註破壞性變更。
49
+
50
+ ## 降級與記錄策略
51
+ - 可能失敗點:<列出>(例如:Vs30 查詢失敗、缺分量、測站不足等)
52
+ - 降級方案:<對應每一種失敗點的替代行為>
53
+ - Logging:<關鍵訊息,包含警告/錯誤的字串格式,便於追蹤>
54
+
55
+ ## 測試與驗收
56
+ - 單元/整合測試:<若有測試框架,列出測試案例>
57
+ - 手動冒煙:
58
+ - 啟動方式:`./run_local.sh` 或 `python app.py`(視專案而定)
59
+ - 驗收步驟:<步驟 1, 2, 3>
60
+ - 預期結果:<清楚描述>
61
+ - 品質檢查(最低):
62
+ - [ ] Build/型別/格式檢查通過
63
+ - [ ] 關鍵邊界情境可正確降級並記錄 log
64
+ - [ ] 與 `spec.md` 之契約一致
65
+
66
+ ## 風險、回滾與後續
67
+ - 風險:<列出主要風險與緩解方式>
68
+ - 回滾:<若出問題如何快速回復>
69
+ - 後續工作:<可選,列出延伸 task>
70
+
71
+ ## 進度紀錄(跨對話追蹤)
72
+ - <YYYY-MM-DD HH:MM>:<進度更新,關鍵決策/PR 連結/驗收結果>
73
+
74
+ ---
75
+
76
+ 附錄 A:Commit 信息模板
77
+
78
+ ```
79
+ feat(task T-<YYYYMMDD>-<shortname>): <簡述>
80
+
81
+ - <子任務/變更點 1>
82
+ - <子任務/變更點 2>
83
+
84
+ Refs: T-<YYYYMMDD>-<shortname>
85
+ ```
86
+
87
+ 附錄 B:分支/PR 命名
88
+ - 分支:`feature/T-<YYYYMMDD>-<shortname>`
89
+ - PR 標題:`T-<YYYYMMDD>-<shortname>: <簡述>`
90
+