Spaces:
Sleeping
Sleeping
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 +61 -0
- .github/copilot-instructions.md +54 -0
- .github/pull_request_template.md +20 -0
- README.md +88 -0
- app.py +50 -73
- change-log.md +39 -0
- docs/task-template.md +91 -0
- spec.md +134 -0
- task.md +90 -0
.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 |
-
#
|
| 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"
|
| 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"
|
| 70 |
except Exception as e:
|
| 71 |
-
logger.error(f"
|
| 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 |
-
#
|
| 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"
|
| 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"
|
| 90 |
except Exception as e:
|
| 91 |
-
logger.error(f"
|
| 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 |
-
|
| 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 |
-
#
|
| 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"
|
| 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"
|
| 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 |
-
#
|
| 491 |
actual_count = len(selected_stations)
|
| 492 |
if actual_count < n_stations:
|
| 493 |
-
logger.warning(f"
|
| 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 |
-
-
|
| 519 |
-
-
|
| 520 |
-
-
|
| 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 |
-
#
|
| 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 |
-
#
|
| 539 |
needs_padding = duration < min_duration
|
| 540 |
if needs_padding:
|
| 541 |
-
logger.info(f"
|
| 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 |
-
#
|
| 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"
|
| 572 |
|
| 573 |
-
#
|
| 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"
|
| 580 |
|
| 581 |
-
#
|
| 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 |
-
#
|
| 591 |
# 不足 30 秒時,尾段以 0 遮罩補齊
|
| 592 |
waveform_3c = np.zeros((target_length, 3))
|
| 593 |
|
| 594 |
-
#
|
| 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"
|
| 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 |
-
#
|
| 729 |
m = folium.Map(
|
| 730 |
location=[23.5, 121],
|
| 731 |
zoom_start=7,
|
| 732 |
tiles='OpenStreetMap',
|
| 733 |
width='100%',
|
| 734 |
-
height='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 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 880 |
m = folium.Map(
|
| 881 |
location=[epicenter_lat, epicenter_lon],
|
| 882 |
zoom_start=8,
|
| 883 |
tiles='OpenStreetMap',
|
| 884 |
width='100%',
|
| 885 |
-
height='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 |
-
#
|
| 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 |
-
|
| 1047 |
"""
|
| 1048 |
try:
|
| 1049 |
-
#
|
| 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 |
-
#
|
| 1083 |
all_pga_list = []
|
| 1084 |
all_target_names = []
|
| 1085 |
|
| 1086 |
-
#
|
| 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"
|
| 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"
|
| 1134 |
pga_list = all_pga_list
|
| 1135 |
target_names = all_target_names
|
| 1136 |
|
| 1137 |
-
#
|
| 1138 |
intensity_map = create_intensity_map(pga_list, target_names, epicenter_lat, epicenter_lon)
|
| 1139 |
map_html = intensity_map._repr_html_()
|
| 1140 |
|
| 1141 |
-
#
|
| 1142 |
observed_intensity_path = load_observed_intensity_image(event_name)
|
| 1143 |
|
| 1144 |
-
#
|
| 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"
|
| 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 |
+
|