Spaces:
Sleeping
Sleeping
| 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() |