import os import io import json from datetime import datetime, timedelta, timezone import requests import pandas as pd import matplotlib.pyplot as plt import gradio as gr # ----------------------------- # 時區:台北 (UTC+8) # ----------------------------- TAIPEI_TZ = timezone(timedelta(hours=8)) def now_taipei(): return datetime.now(TAIPEI_TZ) def fmt_dt(dt: datetime) -> str: return dt.strftime("%Y-%m-%dT%H:%M:%S") # ----------------------------- # 快速時間範圍 # ----------------------------- def set_time_range(hours: int | None = None, days: int | None = None): """ 依台北時間回傳 (timeFrom, timeTo) 字串(yyyy-MM-ddTHH:mm:ss) """ now = now_taipei() if hours is not None: t_from = now - timedelta(hours=hours) elif days is not None: t_from = now - timedelta(days=days) else: t_from = now - timedelta(days=3) # 預設最近 3 天 return fmt_dt(t_from), fmt_dt(now) # ----------------------------- # 呼叫 CWA E-A0015-001 API # ----------------------------- API_URL = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/E-A0015-001" def fetch_reports(time_from: str, time_to: str) -> dict: """ 以環境變數 CWA_API_KEY 做授權參數呼叫 API,回傳 JSON 物件(dict) """ api_key = os.getenv("CWA_API_KEY", "").strip() if not api_key: raise RuntimeError("環境變數 CWA_API_KEY 未設定。請在 Space Secrets 設定。") params = { "Authorization": api_key, "timeFrom": time_from, "timeTo": time_to, # 其餘參數保持預設;排序改由本地處理,以避免介面差異 } resp = requests.get(API_URL, params=params, timeout=30) resp.raise_for_status() return resp.json() # ----------------------------- # 解析 JSON → pandas.DataFrame # 只保留必要欄位(不含 area / station 等) # ----------------------------- def safe_get(d, *keys, default=None): cur = d for k in keys: if isinstance(cur, dict) and k in cur: cur = cur[k] else: return default return cur def parse_ea0015(json_obj: dict) -> pd.DataFrame: """ 嘗試容錯各種大小寫與路徑差異,輸出欄位: OriginTime, Lat, Lon, Depth_km, Magnitude, Location, ReportURL """ records = json_obj.get("records") or json_obj.get("Records") or {} quakes = ( records.get("earthquake") or records.get("Earthquake") or records.get("data") or [] ) rows = [] for q in quakes: origin = safe_get(q, "originTime") or safe_get(q, "OriginTime") # 經緯度 & 深度 lat = ( safe_get(q, "epicenter", "epicenterLat") or safe_get(q, "Epicenter", "EpicenterLat") ) lon = ( safe_get(q, "epicenter", "epicenterLon") or safe_get(q, "Epicenter", "EpicenterLon") ) depth = ( safe_get(q, "depth") or safe_get(q, "Depth") ) # 規模 mag = ( safe_get(q, "magnitude", "magnitudeValue") or safe_get(q, "Magnitude", "MagnitudeValue") or safe_get(q, "magnitude", "magnitude") or safe_get(q, "Magnitude", "Magnitude") ) # 位置與連結 loc = ( safe_get(q, "epicenter", "location") or safe_get(q, "Epicenter", "Location") ) url = safe_get(q, "reportURL") or safe_get(q, "ReportURL") rows.append({ "OriginTime": origin, "Lat": _to_float(lat), "Lon": _to_float(lon), "Depth_km": _to_float(depth), "Magnitude": _to_float(mag), "Location": loc, "ReportURL": url, }) df = pd.DataFrame(rows) # 轉時間、排序(預設:OriginTime 由新到舊) if not df.empty: df["OriginTime"] = pd.to_datetime(df["OriginTime"], errors="coerce") df = df.sort_values("OriginTime", ascending=False, na_position="last").reset_index(drop=True) return df def _to_float(x): try: if x is None or x == "": return None return float(str(x).strip()) except Exception: return None # ----------------------------- # 視覺化:趨勢圖、台灣地圖 # ----------------------------- TAIWAN_BBOX = (119, 123, 21, 26) # lon_min, lon_max, lat_min, lat_max def plot_trend(df: pd.DataFrame) -> bytes | None: """時間-規模散點圖,輸出 PNG bytes""" if df.empty: return None fig, ax = plt.subplots(figsize=(6, 3.8)) ax.scatter(df["OriginTime"], df["Magnitude"]) ax.set_xlabel("Origin Time (Taipei)") ax.set_ylabel("Magnitude") ax.grid(True, linestyle="--", alpha=0.4) fig.autofmt_xdate() buf = io.BytesIO() fig.savefig(buf, format="png", dpi=160, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.getvalue() def plot_taiwan_map(df: pd.DataFrame) -> bytes | None: """ 基礎台灣範圍框圖(非海岸線),用散點展示震央; 以規模對應 marker 大小,附簡易圖例。 """ if df.empty: return None lon_min, lon_max, lat_min, lat_max = TAIWAN_BBOX fig, ax = plt.subplots(figsize=(6, 6)) # 邊框 ax.set_xlim(lon_min, lon_max) ax.set_ylim(lat_min, lat_max) ax.set_xlabel("Longitude (°E)") ax.set_ylabel("Latitude (°N)") ax.set_title("Epicenters in Taiwan Region (119–123E, 21–26N)") # 散點:大小反映規模 mags = df["Magnitude"].fillna(0) sizes = (mags.clip(lower=0) + 2.0) ** 3 # 調整一下讓差異更明顯 ax.scatter(df["Lon"], df["Lat"], s=sizes, alpha=0.6, edgecolor="black") # 簡單圖例:M3/4/5/6 對應大小 for m in [3, 4, 5, 6]: ax.scatter([], [], s=((m + 2.0) ** 3), alpha=0.6, edgecolor="black", label=f"M {m}") ax.legend(title="Magnitude", loc="upper right", frameon=True) ax.grid(True, linestyle="--", alpha=0.3) buf = io.BytesIO() fig.savefig(buf, format="png", dpi=160, bbox_inches="tight") plt.close(fig) buf.seek(0) return buf.getvalue() # ----------------------------- # 主流程:查詢 + 輸出 # ----------------------------- def query_and_render(time_from: str, time_to: str, sort_order: str): """ 1) 取資料 → 2) 轉成 DataFrame → 3) 依排序輸出 → 4) 回傳表格、圖、CSV Gradio 會接收回傳值更新元件。 """ try: raw = fetch_reports(time_from, time_to) except Exception as e: return gr.update(value=f"查詢錯誤:{e}"), None, None, None try: df = parse_ea0015(raw) if df.empty: return "(查無資料)", None, None, None # 排序(本地) if sort_order == "OriginTime (舊→新)": df = df.sort_values("OriginTime", ascending=True, na_position="last").reset_index(drop=True) else: df = df.sort_values("OriginTime", ascending=False, na_position="last").reset_index(drop=True) # 表格(轉為 markdown 簡表,手機閱讀較清楚) md = df_to_markdown(df) # 圖 trend_png = plot_trend(df) map_png = plot_taiwan_map(df) # 下載 CSV(以 bytes 形式回傳給 DownloadButton) csv_bytes = df.to_csv(index=False).encode("utf-8-sig") return md, trend_png, map_png, csv_bytes except Exception as e: return gr.update(value=f"解析錯誤:{e}"), None, None, None def df_to_markdown(df: pd.DataFrame, top_n: int = 100) -> str: """ 將前 top_n 筆轉為 markdown 表(欄位:OriginTime, Magnitude, Depth_km, Lat, Lon, Location, ReportURL) """ if df.empty: return "(查無資料)" show_cols = ["OriginTime", "Magnitude", "Depth_km", "Lat", "Lon", "Location", "ReportURL"] exist_cols = [c for c in show_cols if c in df.columns] slim = df[exist_cols].head(top_n).copy() # 時間顯示(台北時區) if "OriginTime" in slim.columns: slim["OriginTime"] = slim["OriginTime"].dt.tz_convert(TAIPEI_TZ).dt.strftime("%Y-%m-%d %H:%M:%S %Z") return slim.to_markdown(index=False) # ----------------------------- # Gradio 介面 # ----------------------------- default_from, default_to = set_time_range(days=3) with gr.Blocks(fill_height=True, analytics_enabled=False) as demo: gr.Markdown( """ # CWA 顯著有感地震報告 (E-A0015-001) 此 Space **只使用環境變數 `CWA_API_KEY` 作為授權**。 預設查詢 **最近 3 天(台北時間)**。手機版為單欄顯示。 """ ) # 單欄(手機友好) with gr.Column(): with gr.Row(): time_from = gr.Textbox(label="timeFrom yyyy-MM-ddThh:mm:ss", value=default_from, scale=1) with gr.Row(): time_to = gr.Textbox(label="timeTo yyyy-MM-ddThh:mm:ss", value=default_to, scale=1) # 快速時間範圍(已移除「最近6小時」) with gr.Row(): btn_12h = gr.Button("最近 12 小時") btn_24h = gr.Button("最近 24 小時") btn_3d = gr.Button("最近 3 天") btn_5d = gr.Button("最近 5 天") # 排序(在地端) sort_dd = gr.Dropdown( choices=["OriginTime (新→舊)", "OriginTime (舊→新)"], value="OriginTime (新→舊)", label="排序(本地)" ) run_btn = gr.Button("查詢", variant="primary") # 輸出 table_out = gr.Markdown("(尚未查詢)") trend_out = gr.Image(label="趨勢圖(時間-規模)", type="numpy") map_out = gr.Image(label="台灣範圍圖(119–123E, 21–26N)", type="numpy") dl_btn = gr.DownloadButton(label="下載 CSV", file_name="CWA_E-A0015-001.csv") # 綁定:快速鍵 → 更新時間欄位 btn_12h.click(lambda: set_time_range(hours=12), outputs=[time_from, time_to]) btn_24h.click(lambda: set_time_range(hours=24), outputs=[time_from, time_to]) btn_3d.click(lambda: set_time_range(days=3), outputs=[time_from, time_to]) btn_5d.click(lambda: set_time_range(days=5), outputs=[time_from, time_to]) # 綁定:查詢 → 表格 / 圖 / CSV def _on_query(tfrom, tto, sort_sel): md, trend_png, map_png, csv_bytes = query_and_render(tfrom, tto, sort_sel) # DownloadButton 需要返回 bytes-like 物件 return md, trend_png, map_png, csv_bytes run_btn.click( _on_query, inputs=[time_from, time_to, sort_dd], outputs=[table_out, trend_out, map_out, dl_btn] ) # 注意:Hugging Face Spaces 會自動呼叫 demo.launch() if __name__ == "__main__": demo.launch()